Django Interview Questions and Answers
Last updated:
Check out 60 of the most common Django interview questions, then take an AI-powered practice interview
Q1Walk through Django's MVT request cycle: what happens between an incoming HTTP request and the rendered response?
BasicFundamentals
Answer
Django calls its architecture MVT: Model, View, Template. A request first hits the WSGI or ASGI handler (wsgi.py or asgi.py), which wraps it in an HttpRequest object. The request then passes down the MIDDLEWARE stack in order: SecurityMiddleware, SessionMiddleware, CsrfViewMiddleware, AuthenticationMiddleware and so on, each getting a chance to modify or short-circuit it.
Next the URL resolver loads ROOT_URLCONF, matches the path against your urlpatterns top to bottom, and dispatches to the matched view with any captured parameters. The view is the controller in classic MVC terms: it talks to models through the ORM, applies business logic, and returns an HttpResponse, often by calling render(request, template, context) which pushes the context through the Django template engine. The response then travels back up the middleware stack in reverse order (this is where response headers like Content-Security-Policy or cache headers get attached) and out through the server.
The naming trap interviewers set: Django's 'view' maps to MVC's controller, and Django's 'template' maps to MVC's view; the framework itself handles the controller-ish routing glue. Knowing where middleware sits matters practically, because it explains why request.user does not exist before AuthenticationMiddleware runs, and why a middleware placed above SessionMiddleware cannot read the session.
Key Points
- Request: server handler, then middleware top-down, then URL resolver, then view
- Response: view return value travels back up middleware in reverse order
- Django's view = MVC controller; Django's template = MVC view
- Middleware ordering explains when request.user and request.session exist
Q2What is the difference between a Django project and an app, and how does INSTALLED_APPS wire them together?
BasicFundamentals
Answer
A project is the whole site: the settings package created by django-admin startproject, containing settings.py, the root urls.py, and wsgi.py/asgi.py. An app is a self-contained feature module created by python manage.py startapp, with its own models.py, views.py, admin.py, migrations/ directory and tests. One project composes many apps: a jobs platform might have accounts, jobs, applications, payments and notifications apps.
Registration happens through INSTALLED_APPS in settings: only registered apps get their models detected, their migrations discovered, their templates and static files found by the app-directories loaders, and their admin.py imported. Each app can define an AppConfig subclass in apps.py, which is where you set default_auto_field and hook the ready() method, the sanctioned place to connect signal receivers so they register exactly once at startup. The design intent is reusability: django.contrib.auth and django.contrib.admin are just apps, and third-party packages like django-allauth or rest_framework plug in the same way.
Interviewers use this question to check whether you structure code by feature or dump everything into one giant app. The practical guidance worth saying out loud: keep apps focused around a domain concept, avoid circular imports between apps by referencing models with the 'app_label.ModelName' string form in ForeignKey declarations, and never import models at module level inside apps.py.
# apps.py
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'jobs'
def ready(self):
# register signal receivers exactly once
from . import signals # noqa: F401
# settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'jobs.apps.JobsConfig',
]
Q3Which settings must change before a Django site goes live, and what does manage.py check --deploy actually verify?
BasicConfiguration
Answer
The non-negotiables: DEBUG = False (with DEBUG on, unhandled errors dump your settings, environment and SQL to any visitor), SECRET_KEY loaded from the environment rather than committed (it signs sessions, password reset tokens and cookies, so a leaked key means forged sessions), ALLOWED_HOSTS set to your real domains (Django rejects other Host headers with a 400 to block Host-header poisoning), and CSRF_TRUSTED_ORIGINS listing your HTTPS origins, which Django has required for cross-origin POSTs since the origin-checking change in Django 4.0. Then the TLS block: SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, SECURE_HSTS_SECONDS, and SECURE_PROXY_SSL_HEADER when you terminate TLS at nginx or a load balancer, without which Django thinks every request is plain HTTP and redirect-loops. Static files need STATIC_ROOT plus a collectstatic step, because runserver's static serving does not exist under gunicorn.
Running python manage.py check --deploy audits exactly this list: it warns on DEBUG, missing HSTS, insecure cookies, a weak or default SECRET_KEY, missing X-Content-Type-Options and referrer policy, and more. Interviewers love asking what check --deploy flags because it separates people who have actually deployed Django from people who have only done tutorials. Mention that you run it in CI so a bad settings change fails the pipeline instead of reaching production.
# settings/production.py
import os
DEBUG = False
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
ALLOWED_HOSTS = ['goodspace.ai', 'api.goodspace.ai']
CSRF_TRUSTED_ORIGINS = ['https://goodspace.ai']
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
STATIC_ROOT = '/srv/static'
# CI step:
# python manage.py check --deploy --fail-level WARNING
Q4How does URL routing work in Django: path() converters, include(), namespaces and reverse()?
BasicRouting
Answer
urlpatterns is an ordered list; Django tries each pattern top to bottom and dispatches to the first match, so ordering bugs (a greedy pattern above a specific one) are a classic source of 404s. path() uses readable converters: <int:pk>, <str:slug>, <slug:slug>, <uuid:id>, <path:rest>, which both match and cast the value before it reaches your view, so the view receives an int, not the string '42'. When a converter cannot express the rule, re_path() takes a raw regex with named groups. include() mounts an app's urls.py under a prefix, keeping routing modular, and app_name plus the namespace argument enable namespaced reversing like reverse('jobs:detail', kwargs={'pk': 7}) or {% url 'jobs:detail' pk=7 %} in templates. Reversing is the habit interviewers check for: hardcoding '/jobs/7/' in templates and redirects means every URL restructure is a sitewide grep, while named routes make it a one-line change in urls.py. Two production details worth volunteering: APPEND_SLASH (on by default, via CommonMiddleware) redirects '/jobs' to '/jobs/' with a 301 but only for GET, so a POST to a missing-slash URL silently loses its body on redirect, a real bug people hit with webhooks; and custom path converters can be registered with register_converter() when you want, say, a ULID or a date segment validated at the routing layer instead of inside every view.
# project/urls.py
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('jobs/', include('jobs.urls', namespace='jobs')),
]
# jobs/urls.py
from django.urls import path
from . import views
app_name = 'jobs'
urlpatterns = [
path('', views.JobListView.as_view(), name='list'),
path('<int:pk>/', views.JobDetailView.as_view(), name='detail'),
path('<slug:slug>/apply/', views.apply, name='apply'),
]
# anywhere in code
from django.urls import reverse
url = reverse('jobs:detail', kwargs={'pk': 7}) # '/jobs/7/'
Q5Function-based views versus class-based views in Django: when do you reach for each?
BasicViews
Answer
A function-based view (FBV) is a plain function taking request and returning HttpResponse; a class-based view (CBV) is a class whose as_view() classmethod produces that function, with dispatch() routing by HTTP method to get(), post() and friends. Django's generic CBVs (ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView) encode the standard CRUD patterns: a DetailView with just model = Job and a template gives you object fetching, 404 handling and context for free. CBVs win when your view fits a known shape or when you need reuse through mixins, LoginRequiredMixin and PermissionRequiredMixin being the everyday examples.
FBVs win when logic is bespoke: a webhook handler, a multi-step form, anything where following the generic view's template-method flow (get_queryset, get_context_data, form_valid) would mean overriding four hooks to change one behaviour. The interviewer's real question is whether you understand what as_view() and dispatch() do, because that explains the sharp edges: decorators do not apply directly to classes (you use @method_decorator on dispatch or the specific method), and attributes set on the class are shared across all requests while attributes set on self inside a method are per-request, since as_view() instantiates a fresh instance per request. Mispinning state on the class is a genuine concurrency bug under a threaded gunicorn worker. A senior-sounding summary: generic CBVs for admin-ish CRUD pages, FBVs (or DRF's APIView) for everything with real business logic.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
from django.http import JsonResponse
class JobListView(LoginRequiredMixin, ListView):
model = Job
paginate_by = 20
def get_queryset(self):
# per-request state belongs on self / locals, never on the class
return (Job.objects.filter(is_active=True)
.select_related('company')
.order_by('-created_at'))
# equivalent-intent FBV for a bespoke endpoint
def job_stats(request):
data = {'active': Job.objects.filter(is_active=True).count()}
return JsonResponse(data)
Q6In Django models, what do null, blank, choices and on_delete actually control, and why do people confuse null with blank?
BasicModels
Answer
null and blank operate at different layers, which is exactly why the confusion exists. null=True is a database rule: the column allows NULL. blank=True is a validation rule: forms, ModelForm and full_clean() will accept an empty value. A CharField almost always wants blank=True with null=False, because Django's convention stores empty text as '' rather than NULL, avoiding two distinct 'no value' states that break lookups like exclude(name=''). Date, numeric and ForeignKey fields need both null=True and blank=True to be genuinely optional. choices restricts values at the validation layer (and renders a select widget); since Django 5.0 you can pass an enumeration type or a callable directly.
Crucially, choices is not a database constraint: a raw UPDATE can still write anything, so pair it with a CheckConstraint in Meta when integrity matters. on_delete is mandatory on every ForeignKey and decides what happens when the referenced row dies: CASCADE deletes dependents (right for owned children like OrderItem), PROTECT raises ProtectedError to block the delete (right for master data like a Plan referenced by subscriptions), SET_NULL orphans the reference (requires null=True), and RESTRICT, SET_DEFAULT and DO_NOTHING cover rarer cases. The production gotcha worth naming: Django's CASCADE is emulated in Python by the deletion collector, not by ON DELETE CASCADE in the database, so deleting a row with millions of descendants pulls them into memory and can take down a worker; for those cases people use raw SQL or queryset .delete() in batches.
from django.db import models
class Job(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
ACTIVE = 'active', 'Active'
CLOSED = 'closed', 'Closed'
title = models.CharField(max_length=120) # blank not allowed
description = models.TextField(blank=True) # optional text: '' not NULL
salary_max = models.IntegerField(null=True, blank=True) # optional number
status = models.CharField(max_length=10, choices=Status.choices,
default=Status.DRAFT)
company = models.ForeignKey('companies.Company',
on_delete=models.PROTECT,
related_name='jobs')
class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(status__in=['draft', 'active', 'closed']),
name='job_status_valid',
),
]
Q7What exactly happens when you run makemigrations and migrate, and how does Django know which migrations have been applied?
BasicMigrations
Answer
makemigrations compares your current models against the state produced by replaying every existing migration (it never inspects the live database schema) and writes a new migration file describing the diff as operations: AddField, AlterField, CreateModel and so on. Each file declares dependencies on earlier migrations, forming a graph, which is why deleting a migration file that others depend on breaks the chain with NodeNotFoundError. migrate then applies unapplied migrations in dependency order, wrapping each in a transaction on databases that support transactional DDL (PostgreSQL does; MySQL does not, so a failed mid-migration on MySQL leaves a half-applied schema you must repair by hand). Bookkeeping lives in the django_migrations table: one row per applied migration with app, name and applied timestamp.
That table is also why migrate --fake exists: it records a migration as applied without running it, used when the schema change was made manually or when adopting an existing database with migrate --fake-initial. Supporting commands worth naming: showmigrations prints the applied/unapplied checklist, sqlmigrate app 0007 prints the exact SQL a migration would run (essential before touching a big production table), and migrate app 0005 rolls backward to a named migration if every operation in between is reversible. The interview follow-up is almost always about teams: two branches each adding migration 0008 to the same app collide, and makemigrations --merge creates an empty merge migration that depends on both, restoring a single graph head.
# create migration files from model changes
python manage.py makemigrations jobs
# preview the SQL before running it on a big table
python manage.py sqlmigrate jobs 0008
# apply, inspect state, roll back
python manage.py migrate
python manage.py showmigrations jobs
python manage.py migrate jobs 0007 # backward to 0007
# adopt an existing schema / record without executing
python manage.py migrate --fake-initial
python manage.py migrate jobs 0008 --fake
# resolve two branches that both created 0008
python manage.py makemigrations --merge
Q8QuerySets are lazy: what triggers evaluation, and how does the internal result cache behave?
BasicORM
Answer
Building a QuerySet runs no SQL. Job.objects.filter(is_active=True).exclude(city='Delhi').order_by('-created_at') just composes an SQL description; each chained call returns a new QuerySet without touching the database. Evaluation happens when you actually consume it: iterating in a for loop, calling list(), len(), bool() or repr() on it, slicing with a step, pickling it, or calling terminal methods like first(), exists(), count(), get() and aggregate().
This laziness is what makes patterns like building a base queryset and conditionally adding .filter() clauses cheap. After the first full evaluation, results are stored in the QuerySet's internal result cache, so iterating the same QuerySet object twice runs one query. The classic gotchas interviewers probe: two separate expressions like jobs.count() followed by iterating jobs run two queries, because count() executes SELECT COUNT(*) without populating the cache; if you need both, evaluate once with len(list(jobs)) or just len() on the evaluated queryset.
Conversely, using if queryset: then iterating is fine (bool() fills the cache), but if you only need existence, .exists() is far cheaper than loading rows. Slicing before evaluation compiles to LIMIT/OFFSET in SQL; slicing after evaluation slices the cached Python list. And printing a QuerySet in the shell evaluates it via repr(), which is why the Django shell 'works' while the same code in a view might be an accidental full-table load. Being able to say precisely which line runs SQL is one of the fastest credibility wins in a Django interview.
qs = Job.objects.filter(is_active=True) # no SQL yet
qs = qs.exclude(city='Delhi') # still no SQL
qs = qs.order_by('-created_at')[:20] # LIMIT 20, still lazy
for job in qs: # SQL runs here, cache fills
print(job.title)
for job in qs: # served from result cache
print(job.id)
Job.objects.filter(city='Pune').exists() # SELECT ... LIMIT 1, cheap
Job.objects.filter(city='Pune').count() # SELECT COUNT(*), no cache
fresh = Job.objects.all()
if fresh: # bool() evaluates and caches all rows: fine if
first = fresh[0] # you will iterate anyway, wasteful if not
Q9When do you use get(), filter().first() and get_object_or_404(), and which exceptions can get() raise?
BasicORM
Answer
get() fetches exactly one row and is strict about it: zero matches raises Model.DoesNotExist, and two or more raise MultipleObjectsReturned. That strictness is a feature when the lookup is on a unique field (pk, a unique slug, an email), because silently getting 'some' row would hide data bugs. filter(...).first() returns the first match or None, never raises, and is right when absence is a normal case you will branch on, but note it adds LIMIT 1 with whatever ordering the queryset has, so on an unordered queryset 'first' is whatever the database returns, not necessarily the oldest row. get_object_or_404(Job, pk=pk) is the view-layer convenience: it calls get() and converts DoesNotExist into an Http404, which Django's handler turns into your 404 page. It exists because the alternative, try/except DoesNotExist in every detail view, is boilerplate; there is a matching get_list_or_404().
Two production notes interviewers reward: first, catching the exception should use the model-specific Job.DoesNotExist (each model gets its own subclass) rather than the generic ObjectDoesNotExist, so you never accidentally swallow a different model's miss inside nested lookups. Second, get() on a non-unique field is a latent MultipleObjectsReturned in production waiting for the first duplicate; if the field should be unique, enforce it with a UniqueConstraint so the database, not runtime luck, guarantees the invariant. In DRF, generics do the equivalent via get_object(), which also runs object-level permission checks, so hand-rolled get_object_or_404 calls in APIViews skip permissions unless you call check_object_permissions yourself.
from django.shortcuts import get_object_or_404
# unique lookup: strictness wanted
try:
user = User.objects.get(email='a@b.com')
except User.DoesNotExist:
user = None
except User.MultipleObjectsReturned:
# data bug: email should be unique, log loudly
raise
# absence is normal: no exception dance
latest = Job.objects.filter(company_id=7).order_by('-created_at').first()
# detail views
def job_detail(request, pk):
job = get_object_or_404(Job.objects.select_related('company'), pk=pk)
return render(request, 'jobs/detail.html', {'job': job})
Q10Explain ForeignKey, OneToOneField and ManyToManyField, including related_name and when a through model is required.
BasicModels
Answer
ForeignKey is many-to-one: many Applications point to one Job. It creates a jobs_application.job_id column with an index and, on the other side, a reverse manager: job.application_set by default, or job.applications if you set related_name='applications'. Setting related_name explicitly on every relation is standard practice, both for readability and because two FKs to the same model from one model clash without distinct related names (Django refuses to migrate until you fix it; related_name='+' disables the reverse accessor entirely).
OneToOneField is a ForeignKey with a unique constraint, used for profile-extension patterns (User to CandidateProfile) and as the mechanism behind multi-table inheritance; the reverse accessor returns a single object and raises RelatedObjectDoesNotExist when absent, not None, a detail that regularly surprises people in production tracebacks. ManyToManyField creates a hidden junction table with the two FK columns; job.skills.add(skill), .remove(), .set() and .clear() manage rows in it. The moment the relationship itself carries data (when the skill was added, who endorsed it, a proficiency level), you need an explicit through model: declare skills = models.ManyToManyField(Skill, through='JobSkill') and create rows on JobSkill directly.
Historically .add() was forbidden on through relations; since Django 4.1 you can use .add() with through_defaults for the extra columns. One more probe-worthy detail: m2m changes do not fire on save(), they fire the m2m_changed signal, and in forms ManyToMany data is saved by form.save_m2m() when you used save(commit=False), forgetting that call is a classic 'why are the tags not saving' bug.
class Skill(models.Model):
name = models.CharField(max_length=60, unique=True)
class Job(models.Model):
title = models.CharField(max_length=120)
company = models.ForeignKey('Company', on_delete=models.CASCADE,
related_name='jobs')
skills = models.ManyToManyField(Skill, through='JobSkill',
related_name='jobs')
class JobSkill(models.Model):
job = models.ForeignKey(Job, on_delete=models.CASCADE)
skill = models.ForeignKey(Skill, on_delete=models.CASCADE)
is_mandatory = models.BooleanField(default=False)
class Meta:
constraints = [models.UniqueConstraint(
fields=['job', 'skill'], name='uniq_job_skill')]
# usage
job.skills.add(python, through_defaults={'is_mandatory': True})
company.jobs.filter(skills__name='Django') # reverse + spanning lookup
Q11How do you customise the Django admin with ModelAdmin, and what makes an admin page slow on large tables?
BasicAdmin
Answer
You register a model with @admin.register(Job) on a ModelAdmin subclass and configure the changelist and edit form declaratively: list_display picks the columns (methods allowed, with @admin.display(description=...) for labels), list_filter adds the sidebar filters, search_fields enables the search box (supports related lookups like 'company__name' and prefix matching with '^field'), ordering, date_hierarchy, list_editable, readonly_fields, fieldsets to group the form, and inlines (TabularInline/StackedInline) to edit child rows on the parent page. actions adds bulk operations to the dropdown. The performance question is where interviewers separate tutorial knowledge from operational experience. Common admin slowness has four usual causes.
First, N+1 in list_display: every FK you render fires a query per row unless you set list_select_related = ('company',) or override get_queryset() with select_related/prefetch_related. Second, the changelist runs SELECT COUNT(*) for pagination on every load; on a 50-million-row table that alone can take seconds, and the standard fix is show_full_result_count = False plus, for extreme cases, a Paginator subclass that returns an estimated count from PostgreSQL's reltuples. Third, unindexed search_fields turn the search box into sequential scans with ILIKE '%term%'; restrict search to indexed prefix lookups ('^email') or wire trigram indexes.
Fourth, raw_id_fields (or the newer autocomplete_fields, which needs search_fields on the related admin) must replace default FK dropdowns once the related table has more than a few thousand rows, otherwise the edit page renders a select with every row in it. Saying 'the admin is a database client for staff, not a public surface' and mentioning that you rename the /admin/ URL and gate it behind SSO or VPN also lands well.
from django.contrib import admin
@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
list_display = ('title', 'company', 'status', 'created_at')
list_filter = ('status', 'created_at')
search_fields = ('title', '^company__name')
list_select_related = ('company',) # kill N+1 in the changelist
autocomplete_fields = ('company',) # no giant FK dropdown
readonly_fields = ('created_at',)
show_full_result_count = False # skip the second COUNT(*)
actions = ('close_jobs',)
@admin.action(description='Close selected jobs')
def close_jobs(self, request, queryset):
queryset.update(status='closed')
Q12How does Django template inheritance work, and what does the autoescaping system protect you from?
BasicTemplates
Answer
Django templates compose through inheritance: a base.html defines the page skeleton with named {% block %} tags (title, content, scripts), and child templates start with {% extends 'base.html' %} and override only the blocks they care about, with {{ block.super }} available to append rather than replace. {% include %} handles smaller reusable fragments, and custom inclusion tags cover fragments that need their own logic. Data reaches templates through the context dict a view passes to render(), plus context processors declared in the TEMPLATES setting: those are functions run for every request that inject common variables, which is how {{ request }}, {{ user }}, {{ messages }} and {{ csrf_token }} are available everywhere without each view supplying them. The language is deliberately restricted: no arbitrary Python, only variable resolution with dots (which tries dict key, then attribute, then index, then a zero-argument method call), filters like {{ value|date:'d M Y' }} and {{ text|truncatewords:30 }}, and tags like {% for %}, {% if %}, {% url %}.
Autoescaping is the security half of the design: every interpolated variable is HTML-escaped by default, converting < > ' " & into entities, which neutralises stored and reflected XSS in the normal path. The escape hatches are exactly where audits look: the |safe filter, {% autoescape off %} blocks, and mark_safe() in Python code each declare 'this string is trusted HTML', so any user-influenced data passing through them is an XSS vulnerability. The professional pattern is to keep mark_safe confined to code that just serialised or sanitised the content itself (say, through bleach or nh3) and to treat |safe in a template diff as a review flag. Also worth knowing: Jinja2 can be swapped in through the same TEMPLATES setting for hot paths, since it renders measurably faster, but DTL remains the default for its safety posture and ecosystem of tags.
{# base.html #}
<html>
<head><title>{% block title %}GoodSpace{% endblock %}</title></head>
<body>
{% include 'partials/nav.html' %}
<main>{% block content %}{% endblock %}</main>
</body>
</html>
{# jobs/detail.html #}
{% extends 'base.html' %}
{% block title %}{{ job.title }} | {{ block.super }}{% endblock %}
{% block content %}
<h1>{{ job.title }}</h1>
<p>{{ job.description|linebreaks }}</p> {# escaped, then formatted #}
<a href="{% url 'jobs:apply' slug=job.slug %}">Apply</a>
{% endblock %}
Q13Describe the Django forms validation flow: is_valid(), cleaned_data, clean_<field>() and clean().
BasicForms
Answer
A Form (or ModelForm, which derives fields from a model) is bound by constructing it with request.POST (and request.FILES for uploads). Calling is_valid() runs the full pipeline: each field's to_python() converts the raw string, the field's built-in validators run (required, max_length, EmailField's format check), then your clean_<fieldname>() hooks run for per-field business rules, and finally the form-wide clean() runs for cross-field rules like 'salary_min must not exceed salary_max'. Errors raised as django.core.exceptions.ValidationError anywhere in that chain are collected into form.errors keyed by field ('__all__' for non-field errors added via add_error(None, ...)), and validated values land in form.cleaned_data.
Inside clean(), fields that already failed are absent from cleaned_data, so cross-field logic must use .get() defensively. For ModelForm, save() writes the instance; save(commit=False) returns the unsaved instance so the view can attach request-derived data like the owner, after which you must call save_m2m() for many-to-many fields. Interviewers commonly probe three edges.
One: model-level validation (validators on fields, Meta.constraints) is not automatically identical to form validation; full_clean() runs validators but database constraints only surface as IntegrityError at save time, so a UniqueConstraint violation under a race appears even after is_valid() passed. Two: unbound versus bound forms, GET renders Form() empty, POST binds data. Three: the standard view rhythm is the post/redirect/get pattern, on success return redirect(...), never render, or refreshing the page resubmits. Even in API-first shops this flow matters because DRF serializers copied its shape almost exactly, and the admin runs entirely on it.
from django import forms
class ApplicationForm(forms.ModelForm):
class Meta:
model = Application
fields = ['expected_ctc', 'notice_days', 'cover_note']
def clean_expected_ctc(self):
ctc = self.cleaned_data['expected_ctc']
if ctc and ctc > 10_00_00_000:
raise forms.ValidationError('CTC looks implausible.')
return ctc
def clean(self):
data = super().clean()
if data.get('notice_days', 0) > 90 and not data.get('cover_note'):
self.add_error('cover_note',
'Explain the long notice period.')
return data
def apply(request, pk):
job = get_object_or_404(Job, pk=pk)
form = ApplicationForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
app = form.save(commit=False)
app.job, app.candidate = job, request.user
app.save()
return redirect('jobs:detail', pk=pk) # post/redirect/get
return render(request, 'jobs/apply.html', {'form': form})
Q14How does Django's CSRF protection actually work, and when is csrf_exempt legitimate?
BasicSecurity
Answer
CSRF protection defends against a hostile site making a victim's browser submit state-changing requests with the victim's cookies attached. Django's CsrfViewMiddleware implements a double-submit scheme: a secret is stored client-side (the csrftoken cookie by default, or in the session if CSRF_USE_SESSIONS is on), and every unsafe request (POST, PUT, PATCH, DELETE) must also carry a matching token, either the hidden input rendered by {% csrf_token %} or the X-CSRFToken header that JavaScript clients send after reading the cookie. The middleware compares the two; a mismatch or absence yields the 403 'CSRF verification failed' page.
The token is salted per render, so two renders differ but validate against the same secret, and the secret rotates on login to prevent session fixation. Since the Django 4.0 change, the middleware also checks the Origin/Referer headers against CSRF_TRUSTED_ORIGINS, which now must include the scheme ('https://goodspace.ai'), the source of countless 403s when teams upgrade or put Django behind a new domain. Safe methods (GET, HEAD, OPTIONS) are never checked, which is why state changes over GET are doubly forbidden. csrf_exempt is legitimate only for endpoints authenticated by something other than cookies: payment gateway webhooks (Razorpay, Stripe) verified by signature headers, and machine-to-machine APIs using token or JWT auth in the Authorization header, since a browser will never attach those automatically, CSRF does not apply.
DRF encodes this correctly on its own: SessionAuthentication enforces CSRF, TokenAuthentication and JWT do not. The anti-pattern to call out is slapping csrf_exempt on a cookie-authenticated view to make a 403 go away; that reopens the exact attack the middleware exists to stop.
{# template form #}
<form method='post'>{% csrf_token %}
<button>Apply</button>
</form>
// fetch() client: read cookie, send header
const token = document.cookie.match(/csrftoken=([^;]+)/)[1];
fetch('/api/apply/', {
method: 'POST',
headers: {'X-CSRFToken': token, 'Content-Type': 'application/json'},
body: JSON.stringify({job: 7}),
});
# webhook: signature-verified, cookie auth irrelevant
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def razorpay_webhook(request):
verify_signature(request.body,
request.headers['X-Razorpay-Signature'])
...
Q15How do authenticate(), login(), logout() and login_required fit together in django.contrib.auth, and how are passwords stored?
BasicAuthentication
Answer
django.contrib.auth ships the User model, middleware, views and password machinery. authenticate(request, username=..., password=...) walks the AUTHENTICATION_BACKENDS list (ModelBackend by default) and returns a User or None; it performs no session work. login(request, user) is what actually signs the user in: it stores the user's pk and the backend path in the session and rotates the session key to block session fixation. From then on AuthenticationMiddleware lazily attaches request.user on every request (an instance or AnonymousUser). logout(request) flushes the session entirely. Access control at the view layer is @login_required for FBVs and LoginRequiredMixin for CBVs, both redirecting anonymous users to settings.LOGIN_URL with a ?next= parameter; Django 5.1 added LoginRequiredMiddleware, which flips the default so every view requires login unless marked with @login_not_required, a much safer posture for internal tools where one forgotten decorator used to mean an exposed page.
Passwords are never stored; PASSWORD_HASHERS defines an ordered list with PBKDF2-SHA256 first by default, and the stored string encodes algorithm, iteration count, salt and hash. Because the list is ordered, Django transparently upgrades a user's hash to the strongest scheme on their next successful login, which is how you migrate to Argon2 (install argon2-cffi, put Argon2PasswordHasher first) without a bulk reset. Related pieces worth naming before the interviewer asks: set_password()/check_password() on User (never assign the field directly), AUTH_PASSWORD_VALIDATORS for policy, the built-in auth views (LoginView, PasswordResetView and friends) that ship with tested flows, and the fact that password reset tokens are signed with SECRET_KEY, one more reason key rotation must be planned rather than improvised.
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
def login_view(request):
user = authenticate(request,
username=request.POST['email'],
password=request.POST['password'])
if user is None:
return render(request, 'login.html',
{'error': 'Invalid credentials'}, status=401)
login(request, user) # session created, key rotated
return redirect('dashboard')
@login_required # anonymous -> LOGIN_URL?next=...
def dashboard(request):
return render(request, 'dashboard.html')
# settings.py: opt into Argon2, keep PBKDF2 for old hashes
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]
Q16Why must a custom user model (AUTH_USER_MODEL) be configured before the first migration, and AbstractUser versus AbstractBaseUser?
BasicAuthentication
Answer
AUTH_USER_MODEL tells every part of Django which model represents a user. Dozens of things hang off it: the FK targets in sessions-adjacent tables, admin log entries, permissions, and every ForeignKey you write via settings.AUTH_USER_MODEL. Because migrations bake concrete table references into their dependency graph, swapping the user model after auth's initial migration has run means every FK in the graph points at auth_user while your new model creates a different table; Django's docs flatly describe changing it mid-project as a manual, error-prone surgery involving rewriting migration history.
Hence the standard advice, which interviewers expect you to recite and justify: every new project defines a custom user model on day one, even if it is just class User(AbstractUser): pass, so that adding fields later is an ordinary migration. The two base classes serve different depths of customisation. AbstractUser keeps Django's full field set (username, email, first/last name, is_staff, is_active, date_joined) and all machinery; you subclass it to add fields or to set USERNAME_FIELD = 'email' with username removed for email-login products, which nearly every Indian consumer app wants.
AbstractBaseUser strips down to password and last_login; you define every other field, write a custom BaseUserManager with create_user/create_superuser, and set USERNAME_FIELD and REQUIRED_FIELDS yourself, the right choice for phone-number-plus-OTP models where Django's defaults are noise. Either way you point admin at it and reference it everywhere as settings.AUTH_USER_MODEL, never by importing the class into a models.py FK (import-time coupling breaks app loading order), and use get_user_model() in runtime code.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
username = None # email-first product
email = models.EmailField(unique=True)
phone = models.CharField(max_length=15, blank=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
objects = UserManager() # custom BaseUserManager
# settings.py (BEFORE the first migrate ever runs)
AUTH_USER_MODEL = 'accounts.User'
# referencing it elsewhere
from django.conf import settings
class Application(models.Model):
candidate = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
Q17Static files versus media files in Django: what do STATIC_ROOT, collectstatic and MEDIA_ROOT each do in production?
BasicStatic & Media
Answer
Static files are your own assets shipped with the code: CSS, JS, images, fonts. Media files are user uploads: resumes, profile photos, anything written at runtime through FileField/ImageField. Django treats them completely differently and conflating them is a deploy-day classic.
For statics, STATIC_URL is the URL prefix, STATICFILES_DIRS lists extra source directories, and django.contrib.staticfiles finds files inside each app's static/ folder. In development runserver serves them automatically; in production it does not, so python manage.py collectstatic copies everything into STATIC_ROOT, a single directory that nginx, a CDN, or WhiteNoise then serves. WhiteNoise (add its middleware right after SecurityMiddleware) is the common containerised setup because it lets gunicorn serve statics efficiently with compression and far-future cache headers, no separate web server required.
Pair it with ManifestStaticFilesStorage (via the STORAGES setting), which hashes filenames like app.3f2d1c.css so browsers can cache forever and deploys bust caches automatically; the gotcha is that a template referencing a missing file makes collectstatic or rendering fail loudly, which is a feature. Media is different: MEDIA_ROOT is where uploads land, MEDIA_URL is their prefix, and Django will not serve them in production at all. On any multi-server or containerised setup local disk is wrong anyway (pods are ephemeral, disks are not shared), so production media goes to object storage, S3 or GCS, through django-storages, with private buckets and signed URLs for anything sensitive like resumes. Interviewers often finish with 'why did your CSS 404 after deploying', and the expected diagnosis is: collectstatic not run, STATIC_ROOT unset, or the server not mapped to serve STATIC_URL.
# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'assets']
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' # dev only; S3 in production
STORAGES = {
'default': { # media -> S3 via django-storages
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {'bucket_name': 'gs-uploads', 'default_acl': 'private'},
},
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage',
},
}
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# ...
]
# deploy step: python manage.py collectstatic --noinput
Q18How do Django sessions work, and how do the db, cache, cached_db and signed_cookies backends differ?
BasicSessions
Answer
SessionMiddleware gives every request a dict-like request.session. The browser holds only an opaque sessionid cookie; the payload lives server-side (except with the cookie backend) wherever SESSION_ENGINE points. Writes are lazy: Django saves the session at the end of the request only if it was modified, or on every request if SESSION_SAVE_EVERY_REQUEST is on.
Mutating a nested structure in place (request.session['cart']['items'].append(...)) does not mark it dirty, the classic 'my session change vanished' bug, fixed by reassigning the key or setting request.session.modified = True. Backend choice is a real production decision. The default django.contrib.sessions.backends.db stores rows in django_session: durable, but adds a database read per request for logged-in users and the table bloats until you cron clearsessions. cache stores sessions only in Redis/Memcached: fastest, but a cache flush or eviction logs everyone out, acceptable for short-lived sessions only. cached_db writes to both and reads through the cache: the usual production pick, database durability with cache-speed reads. signed_cookies stores the session data in the cookie itself, signed with SECRET_KEY: zero server storage, but the payload is readable by the user (signed, not encrypted), limited to 4KB, cannot be revoked server-side (logout elsewhere is impossible), and a leaked SECRET_KEY means arbitrary session forgery, so most security reviews reject it for anything beyond trivial preferences.
Expiry knobs: SESSION_COOKIE_AGE (default two weeks), SESSION_EXPIRE_AT_BROWSER_CLOSE, and per-session set_expiry(). Security-relevant settings interviewers expect unprompted: SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY (on by default, keeps JS away from the cookie), and SESSION_COOKIE_SAMESITE='Lax' as the modern default.
# settings.py: read-through cache with DB durability
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
CACHES = {'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://redis:6379/1',
}}
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
# views
request.session['onboarding_step'] = 3 # marked dirty, will save
cart = request.session.get('cart', {})
cart.setdefault('items', []).append(job_id)
request.session['cart'] = cart # reassign to mark dirty
request.session.set_expiry(3600) # this session: 1 hour
# cron (db/cached_db backends):
# python manage.py clearsessions
Q19How do you write custom middleware in modern Django, and why does its position in the MIDDLEWARE list matter?
BasicMiddleware
Answer
Modern middleware is a callable factory: a function (or class) that receives get_response and returns a callable taking request. Code before the get_response(request) call runs on the way in, top of the list first; code after it runs on the way out, bottom first, like an onion. Returning an HttpResponse without calling get_response short-circuits everything deeper, which is how rate limiters and maintenance-mode switches work.
Two optional hooks extend it: process_view (runs after URL resolution, before the view, and is how CsrfViewMiddleware inspects the matched view for csrf_exempt) and process_exception (runs when the view raises, how Sentry's Django integration captures errors). Ordering is not stylistic, it is causal. SecurityMiddleware sits first so redirects and HSTS apply before any work happens.
SessionMiddleware must precede AuthenticationMiddleware because request.user is looked up from the session. CsrfViewMiddleware needs the session available when CSRF_USE_SESSIONS is on. Anything reading request.user, your audit-logging or tenant-resolution middleware, must sit below AuthenticationMiddleware.
CommonMiddleware's APPEND_SLASH redirect happens on the way in, so a middleware above it sees the original slashless path. A subtlety worth volunteering: middleware is instantiated once at startup, so instance attributes are shared across all requests in a worker; per-request state belongs on the request object itself (request.tenant = ...), never on self, or you get cross-request bleed under threaded workers. In async deployments Django marks each middleware as sync- or async-capable, and each sync/async boundary in the stack costs a thread hop, so a fully async view behind sync-only middleware quietly loses much of its benefit.
import time, logging
logger = logging.getLogger('request_timing')
def timing_middleware(get_response):
def middleware(request):
start = time.monotonic()
response = get_response(request) # everything deeper
ms = (time.monotonic() - start) * 1000
response['Server-Timing'] = f'app;dur={ms:.0f}'
if ms > 500:
logger.warning('slow request %s %s: %.0fms',
request.method, request.path, ms)
return response
return middleware
# settings.py (position matters)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.timing_middleware', # below auth: can read request.user
]
Q20Beyond runserver, which manage.py commands do you actually use day to day, and how do you write a custom management command?
BasicTooling
Answer
The working set: python manage.py shell for an ORM-ready REPL (with django-extensions, shell_plus auto-imports models; Django 5.2 made auto-imports part of the built-in shell), dbshell to drop into psql/mysql with the app's credentials, makemigrations/migrate/showmigrations/sqlmigrate for schema flow, createsuperuser, changepassword, collectstatic, test, check and check --deploy, diffsettings to see what deviates from defaults, flush (development only: empties all tables), loaddata/dumpdata for fixtures, and clearsessions in cron for the db session backend. Knowing sqlmigrate and showmigrations specifically signals production maturity, because both are about inspecting before acting. Custom management commands are how recurring operational scripts should ship instead of ad-hoc paste-into-shell snippets: create yourapp/management/commands/backfill_slugs.py containing a Command(BaseCommand) with add_arguments() for CLI flags and handle() for the body.
They inherit the full Django context (settings, ORM, logging), get --help for free, and can be invoked from cron, a Kubernetes CronJob, or CI. Established conventions interviewers like hearing: always support a --dry-run flag for anything destructive, write progress with self.stdout.write(self.style.SUCCESS(...)) rather than print so output is capturable and testable via call_command(), batch large updates with iterator() and bulk_update() rather than row-at-a-time saves, and exit non-zero through CommandError so schedulers detect failure. A follow-up that separates levels: long-running commands hold a database connection, so on a PgBouncer or CONN_MAX_AGE setup they should close_old_connections() around long idle gaps, and anything that must not run twice concurrently needs an advisory lock or a locked flag row.
# jobs/management/commands/close_expired_jobs.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from jobs.models import Job
class Command(BaseCommand):
help = 'Close jobs whose deadline has passed'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true')
def handle(self, *args, **opts):
qs = Job.objects.filter(status='active',
deadline__lt=timezone.now())
count = qs.count()
if opts['dry_run']:
self.stdout.write(f'Would close {count} jobs')
return
qs.update(status='closed')
self.stdout.write(self.style.SUCCESS(f'Closed {count} jobs'))
# cron: python manage.py close_expired_jobs
# tests: call_command('close_expired_jobs', dry_run=True)
Q21What do values(), values_list(), exclude() and field lookups like __icontains, __in and __gte compile to, and when do values() queries beat model instances?
BasicORM
Answer
Field lookups are the double-underscore suffixes that become SQL predicates: __exact (the implicit default), __iexact, __contains/__icontains (LIKE/ILIKE with wrapped percent signs), __startswith, __in (SQL IN, also accepts a queryset which compiles to a subquery), __gte/__lte/__gt/__lt, __range (BETWEEN), __isnull, __date/__year/__month for datetimes, and traversal across relations like company__city__name='Pune', which the ORM turns into JOINs. exclude() is the negation and comes with the interview-famous multivalued-relation subtlety: filter(applications__status='rejected') means 'jobs having at least one rejected application', but exclude(applications__status='rejected') means 'jobs with no rejected application at all', which is not the row-wise opposite; chained filter() calls on the same related model also differ from a single filter() with two conditions, because each chained call may generate a separate JOIN. values('title', 'company__name') switches the queryset to returning dicts, and values_list('id', flat=True) returns a flat list of scalars; both skip model instantiation entirely. That is the performance angle: hydrating full model instances costs Python-object construction per row and drags every column across the wire, so for exports, dropdowns, id-lists feeding an __in filter, or any read of three columns from a million rows, values()/values_list() is dramatically lighter, and only()/defer() are the middle ground when you still need instances. The trade: dicts have no methods or properties, no save(), and no select_related traversal, you name the joined columns explicitly. A closing detail that lands well: __icontains on a big table cannot use a normal B-tree index (leading wildcard), so 'search by name' features either constrain to __istartswith, add a PostgreSQL trigram GIN index, or move to real search infrastructure.
# dicts, no model instantiation
rows = (Job.objects
.filter(status='active', salary_max__gte=15_00_000)
.values('id', 'title', 'company__name')[:100])
# flat scalar list feeding a subquery-style filter
hot_ids = Application.objects.filter(
created_at__gte=week_ago).values_list('job_id', flat=True)
trending = Job.objects.filter(id__in=list(hot_ids))
# multivalued exclude subtlety:
Job.objects.filter(applications__status='rejected') # >=1 rejected
Job.objects.exclude(applications__status='rejected') # zero rejected
# relation traversal becomes JOINs
Job.objects.filter(company__city__name__iexact='pune',
created_at__date=date(2026, 8, 1))
Q22What are the essential HttpRequest and HttpResponse APIs: request.GET/POST/FILES/headers, JsonResponse, and the render/redirect shortcuts?
BasicViews
Answer
HttpRequest wraps everything about the incoming request. request.GET and request.POST are immutable QueryDicts (multi-value aware: use .getlist('skill') for repeated keys); request.POST only contains form-encoded or multipart bodies, so a JSON API reads json.loads(request.body) instead, and touching request.POST first can consume the stream, order matters when mixing. request.FILES holds UploadedFile objects for multipart uploads, request.headers gives case-insensitive header access (request.headers['X-Request-Id']), request.META carries the raw WSGI environ including REMOTE_ADDR, and request.method, request.path, request.build_absolute_uri(), request.is_secure() and request.get_host() cover routing and URL construction. get_host() respects ALLOWED_HOSTS and, behind a proxy, USE_X_FORWARDED_HOST, part of the Host-header-poisoning defence. On the way out, HttpResponse takes content, status and content_type, and supports dict-style header assignment: response['Cache-Control'] = 'no-store'. JsonResponse serialises a dict with the correct content type; passing a list requires safe=False, a guard dating from a legacy browser attack on top-level JSON arrays.
Specialised subclasses cover the common cases: HttpResponseRedirect (the redirect() shortcut resolves view names through reverse for you), HttpResponseNotFound, HttpResponseForbidden, FileResponse for efficient file streaming with correct headers, and StreamingHttpResponse for generated content too large to buffer. The everyday shortcuts from django.shortcuts, render(request, template, context), redirect(), get_object_or_404(), are what real views are built from. Interview probes here are practical: how do you read the client IP correctly behind nginx (X-Forwarded-For, leftmost trusted hop, never raw REMOTE_ADDR), why does request.POST come back empty for a fetch() sending JSON (wrong parser, read body), and why setting cookies happens on the response (response.set_cookie with secure/httponly/samesite flags) not the request.
import json
from django.http import JsonResponse, FileResponse
from django.views.decorators.http import require_POST
@require_POST
def save_search(request):
if request.content_type == 'application/json':
payload = json.loads(request.body)
else:
payload = {'q': request.POST.get('q', ''),
'skills': request.POST.getlist('skill')}
resp = JsonResponse({'saved': True, 'query': payload})
resp.set_cookie('last_search', payload.get('q', ''),
max_age=86400, secure=True,
httponly=True, samesite='Lax')
return resp
def download_resume(request, pk):
resume = get_object_or_404(Resume, pk=pk, owner=request.user)
return FileResponse(resume.file.open('rb'),
as_attachment=True,
filename='resume.pdf')
Q23What actually changes when DEBUG is True, and what are the specific dangers of running it in production?
BasicConfiguration
Answer
DEBUG=True flips Django into development mode across several subsystems at once. Error handling: any unhandled exception renders the yellow technical 500 page containing the full traceback, local variables at every frame, all settings (secrets are masked only by naming convention, SECRET_KEY and anything containing KEY/PASSWORD/TOKEN, custom secret names leak in full), request headers and the SQL of recent queries. That page alone is why DEBUG in production is treated as a critical vulnerability: it hands an attacker your internal paths, package versions, database names and often credentials.
Static serving: runserver serves static and, with a urls.py helper, media files; with DEBUG off that vanishes, the classic 'site deployed, CSS gone' moment. Host validation: an empty ALLOWED_HOSTS is permitted under DEBUG (localhost variants allowed); with DEBUG off Django refuses to boot requests without explicit hosts. Error reporting: with DEBUG off, unhandled exceptions render your 500 template and can email ADMINS via the mail_admins logging handler instead of exposing details.
Caching of template loaders and other small behaviours differ too. The subtle one interviewers fish for is memory: with DEBUG on, every database connection records every executed query in connection.queries for the process lifetime, so a long-lived worker or a big management command grows memory without bound, people have genuinely diagnosed 'production memory leak' down to a DEBUG=True container. Guard rails: drive DEBUG from an environment variable that defaults to off, assert it in CI with check --deploy, and never branch business logic on DEBUG; use explicit feature settings instead, so staging can run DEBUG off while still being distinguishable from production.
Key Points
- Technical 500 page leaks traceback, settings, headers and SQL to visitors
- Static/media serving and lax ALLOWED_HOSTS only exist under DEBUG
- connection.queries grows unbounded per connection: real memory leak
- DEBUG must default off and be asserted in CI via check --deploy
Q24How does Django handle time zones with USE_TZ, and what goes wrong when Indian teams mix naive datetimes with timezone.now()?
BasicConfiguration
Answer
With USE_TZ=True (the default in new projects since Django 4.0, opt-in before that), Django stores datetimes in UTC in the database and works with aware datetime objects carrying tzinfo. TIME_ZONE (typically 'Asia/Kolkata' for Indian products) then controls display: template rendering converts aware datetimes to the current time zone automatically, and forms interpret user input in it. The cardinal rule is to generate 'now' with django.utils.timezone.now(), which returns an aware UTC datetime, never datetime.datetime.now(), which returns a naive local-time value.
Mixing them produces two failure modes seen constantly in Indian codebases: comparing aware and naive datetimes raises TypeError ('can't compare offset-naive and offset-aware datetimes'), and saving a naive datetime under USE_TZ triggers the RuntimeWarning 'received a naive datetime while time zone support is active' while Django assumes the naive value is in TIME_ZONE, silently shifting stored times by 5 hours 30 minutes if the value was actually UTC, the classic 'all our report timestamps are off by 5:30' bug. The toolbox: timezone.now(), timezone.localtime(dt) to convert for display or business rules, timezone.make_aware()/make_naive() for boundaries with external systems, timezone.activate() per request when users span time zones, and in templates the {% timezone %} tag plus the |date filter which renders in the active zone. Two India-specific traps worth naming: IST has a half-hour offset, so bugs shift by 5:30 rather than a clean hour and evade casual eyeballing; and date-boundary logic ('jobs posted today') must compute the day range in IST then compare in UTC, using timezone.localdate() rather than truncating a UTC timestamp, or your daily metrics roll over at 5:30 AM IST. Store UTC, convert at the edges, is the sentence to say.
from django.utils import timezone
from datetime import timedelta
# settings.py
USE_TZ = True
TIME_ZONE = 'Asia/Kolkata'
# correct 'now' and comparisons
now = timezone.now() # aware, UTC
Job.objects.filter(deadline__lt=now)
# 'posted today' in IST terms
today_ist = timezone.localdate() # date in Asia/Kolkata
start = timezone.make_aware(
datetime.combine(today_ist, time.min)) # 00:00 IST -> aware
Job.objects.filter(created_at__gte=start)
# display
local = timezone.localtime(job.created_at) # 2026-08-11 14:05 IST
# WRONG: naive, warns, and may store shifted by +5:30
# job.deadline = datetime.now() + timedelta(days=7)
Q25select_related versus prefetch_related: how does each kill N+1 queries, and when do you need a Prefetch object?
IntermediateORM Performance
Answer
The N+1 pattern is one list query followed by one query per row as you touch a relation in a loop: rendering 50 jobs with {{ job.company.name }} fires 51 queries. select_related fixes it for single-valued relations (ForeignKey, OneToOne) by adding a SQL JOIN and hydrating the related object in the same query; it can chain through relations ('company__city') and is essentially free to add. It cannot work for multi-valued relations, a JOIN against applications would duplicate each job row per application. prefetch_related handles those (reverse FK sets, ManyToMany): it runs one additional query per relation with WHERE id IN (...) covering all parent ids, then stitches objects together in Python, so 51 queries become 2. The Prefetch object is the control knob people miss: Prefetch('applications', queryset=Application.objects.filter(status='shortlisted').select_related('candidate'), to_attr='shortlisted') lets you filter, order and further optimise the prefetched queryset and stash it on a custom attribute.
Without it, any additional .filter() on job.applications.all() inside the loop discards the prefetch cache and reopens N+1, the single most common regression: the prefetch only caches the exact queryset it ran. Same trap with .count() on a prefetched relation (use len() of the cached list) and with slicing. Detection belongs in tooling, not eyeballs: django-debug-toolbar's SQL panel in development, assertNumQueries in tests pinning the query count for hot endpoints, and APM traces in production. In DRF, the N+1 usually hides in the serializer, nested serializers touching relations per item, so get_queryset() must mirror whatever the serializer traverses; a reviewer who asks 'what does the serializer touch that the queryset does not prefetch' finds bugs in most codebases.
from django.db.models import Prefetch
# 51 queries
for job in Job.objects.all()[:50]:
print(job.company.name)
# 1 query (JOIN)
jobs = Job.objects.select_related('company')[:50]
# 2 queries (IN + stitch)
jobs = Job.objects.prefetch_related('skills')[:50]
# controlled prefetch: filtered, optimised, custom attribute
jobs = Job.objects.select_related('company').prefetch_related(
Prefetch('applications',
queryset=(Application.objects
.filter(status='shortlisted')
.select_related('candidate')),
to_attr='shortlisted'))
for job in jobs:
for app in job.shortlisted: # no extra queries
print(app.candidate.email)
# TRAP: job.applications.filter(...) inside the loop ignores the
# prefetch cache and reintroduces N+1.
Q26What problems do F() and Q() expressions solve, and why is F() the correct way to increment a counter?
IntermediateORM
Answer
F() references a database column inside a query, letting the database compute with it instead of Python. The canonical use is the race-free counter: job.views += 1 followed by save() is read-modify-write, two workers interleaving both read 100 and both write 101, losing an increment; Job.objects.filter(pk=pk).update(views=F('views') + 1) compiles to UPDATE ... SET views = views + 1, which the database executes atomically per row, no lost updates regardless of concurrency.
F() also enables column-to-column comparisons impossible with plain kwargs, filter(salary_max__lt=F('salary_min') * 2), annotations combining fields, and update expressions like update(deadline=F('deadline') + timedelta(days=7)). Two gotchas: after an F() update the in-memory instance still holds the stale value (and re-saving it can re-apply the expression), so call refresh_from_db() before reading; and combining F() with nullable columns propagates NULL through arithmetic, guard with Coalesce. Q() objects make predicates composable: they wrap filter conditions as objects combinable with | (OR), & (AND) and ~ (NOT), which plain filter(kwargs) cannot express since kwargs always AND.
Q(city='Pune') | Q(is_remote=True) is the everyday OR; ~Q(status='closed') negates; and complex search filters get built incrementally, start with Q(), then AND in optional conditions per supplied parameter. Ordering rule: positional Q objects must precede keyword arguments in the same filter() call. Worth adding in an interview: since Django 4.1, Q objects support XOR with ^, filter conditions inside aggregates take a filter=Q(...) argument, and once conditions get deep, conditional aggregation with Case/When plus Q reads better than a jungle of parentheses.
from django.db.models import F, Q
from django.db.models.functions import Coalesce
# atomic increment: no read-modify-write race
Job.objects.filter(pk=pk).update(views=F('views') + 1)
job.refresh_from_db(fields=['views']) # instance had stale value
# column-vs-column filter
suspicious = Job.objects.filter(salary_min__gt=F('salary_max'))
# composable OR / NOT search
q = Q(status='active') & ~Q(company__is_blacklisted=True)
if city:
q &= Q(city__iexact=city) | Q(is_remote=True)
if min_ctc:
q &= Q(salary_max__gte=min_ctc)
results = Job.objects.filter(q)
# NULL-safe arithmetic in an update
Job.objects.update(score=Coalesce(F('score'), 0) + 10)
Q27aggregate() versus annotate() in the Django ORM, and why do multiple annotations over different relations produce inflated counts?
IntermediateORM
Answer
aggregate() collapses a queryset into one summary dict and terminates it: Job.objects.aggregate(total=Count('id'), avg_ctc=Avg('salary_max')) returns {'total': ..., 'avg_ctc': ...}. annotate() adds a computed column per row and keeps the queryset chainable: Company.objects.annotate(job_count=Count('jobs')) gives every company a .job_count you can filter and order by, filter(job_count__gt=10).order_by('-job_count'), which compiles to GROUP BY with HAVING. The aggregate vocabulary is Count, Sum, Avg, Min, Max, StdDev, plus the powerful filter=Q(...) argument for conditional aggregation: annotate(active_jobs=Count('jobs', filter=Q(jobs__status='active'))) computes a filtered count without subqueries. The inflated-count trap is the definitive interview probe here.
Annotating across two multi-valued relations at once, Company.objects.annotate(jobs_n=Count('jobs'), recruiters_n=Count('recruiters')), makes SQL join companies to jobs AND recruiters, producing a row per (job, recruiter) combination; both counts come back multiplied by the other relation's cardinality. Same mechanism inflates Sum and Avg. Fixes, in preference order: Count('jobs', distinct=True) when it is Count; or compute each aggregate in its own Subquery with OuterRef, which stays correct for Sum/Avg and often faster since it avoids the giant join; or run separate annotated queries. Related follow-ups worth pre-empting: values() before annotate() changes grouping (values('city').annotate(n=Count('id')) groups by city, the ORM's GROUP BY idiom), annotation names must not collide with model field names (Django raises ValueError), and ordering plus annotation interacts with Meta.ordering, a default ordering sneaks its column into GROUP BY and silently changes results, so .order_by() (empty) before grouping is a defensive habit.
from django.db.models import Count, Avg, Sum, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
# summary dict
Job.objects.aggregate(total=Count('id'), avg_max=Avg('salary_max'))
# per-row annotation, then filter on it (HAVING)
busy = (Company.objects
.annotate(active_jobs=Count('jobs', filter=Q(jobs__status='active')))
.filter(active_jobs__gte=5))
# WRONG: two multi-valued joins multiply both counts
# Company.objects.annotate(j=Count('jobs'), r=Count('recruiters'))
# RIGHT: subqueries stay correct for any aggregate
jobs_sq = (Job.objects.filter(company=OuterRef('pk'))
.order_by().values('company')
.annotate(n=Count('id')).values('n'))
companies = Company.objects.annotate(
jobs_n=Coalesce(Subquery(jobs_sq, output_field=IntegerField()), 0))
# GROUP BY city
Job.objects.order_by().values('city').annotate(n=Count('id'))
Q28How do transaction.atomic, ATOMIC_REQUESTS, savepoints and select_for_update work together, and what is transaction.on_commit for?
IntermediateTransactions
Answer
Django runs in autocommit by default: each ORM write commits immediately. transaction.atomic, as a decorator or context manager, opens a transaction; on clean exit it commits, on exception it rolls back and re-raises. Nesting atomic blocks does not open nested transactions, inner blocks become SAVEPOINTs, so an inner failure can roll back to the savepoint while the outer transaction survives if you catch the exception outside the inner block, the pattern for 'try this sub-step, tolerate its failure'. Catching an exception inside an atomic block and continuing to query is the classic mistake: on PostgreSQL the transaction is poisoned and subsequent queries raise 'current transaction is aborted'; the correct shape wraps the risky part in its own inner atomic.
ATOMIC_REQUESTS=True (per database in DATABASES) wraps every view in a transaction, simple correctness for CRUD apps, but it holds a connection and transaction open for the whole view, hurting throughput on slow views and breaking streaming responses; non_atomic_requests opts individual views out. select_for_update() locks the selected rows (SELECT ... FOR UPDATE) until commit so concurrent workers serialise on them, mandatory for read-then-decide-then-write flows like seat allocation or wallet debits; it must run inside atomic (Django raises otherwise), and nowait=True or skip_locked=True control contention behaviour. transaction.on_commit(callback) solves a notorious production bug class: firing a Celery task or sending an email inside atomic can have the worker execute before the transaction commits, the task reads the database and finds nothing (DoesNotExist), or worse acts on data that then rolls back. on_commit defers the callback until after successful commit and drops it on rollback. Interviewers often close with 'why did your Celery task intermittently fail to find the row it was given', and on_commit is the expected diagnosis.
from django.db import transaction, IntegrityError
@transaction.atomic
def hire(application_id):
app = (Application.objects
.select_for_update() # row locked till commit
.select_related('job')
.get(pk=application_id))
if app.job.openings_left <= 0:
raise NoOpenings()
app.status = 'hired'
app.save(update_fields=['status'])
app.job.openings_left -= 1
app.job.save(update_fields=['openings_left'])
# tolerate failure of a sub-step via savepoint
try:
with transaction.atomic():
award_referral_bonus(app)
except BonusError:
pass # outer txn still healthy
# task fires only after COMMIT succeeds
transaction.on_commit(
lambda: send_offer_letter.delay(app.id))
Q29How do custom managers and custom QuerySets work, and why is QuerySet.as_manager() usually better than overriding Manager.get_queryset()?
IntermediateORM
Answer
A custom QuerySet is where reusable query logic belongs: subclass models.QuerySet, add chainable methods like .active() or .in_city(name), and attach it with objects = JobQuerySet.as_manager(). Because each method returns a queryset, they compose: Job.objects.active().in_city('Bengaluru').with_company(). This is the Django idiom for a query vocabulary, it keeps filters named, tested and DRY instead of copy-pasted filter() chains drifting apart across views, and it reads like the domain.
The alternative people reach for first, a Manager subclass overriding get_queryset() to pre-filter (say, excluding soft-deleted rows), is a well-documented foot-gun as the default manager: the filter becomes invisible and global. Symptoms teams actually hit: the admin cannot show or restore soft-deleted rows, related managers behave unexpectedly since related object traversal uses its own pathway (base managers), dumpdata exports partial data, and every developer eventually loses an afternoon to 'the row exists in psql but .get() raises DoesNotExist'. The safer layout is explicit: keep objects as the plain (or vocabulary-only) manager, and expose the filtered view as a named alternative manager or a named method, Job.objects.alive() and Job.all_objects respectively; Meta.default_manager_name pins which manager Django itself uses for related descriptors.
Also worth knowing for interviews: the first manager declared on a model becomes _default_manager, managers are not inherited onto multi-table children the way people expect (abstract bases carry them cleanly), and 'manager methods' that create objects (like a create_with_slug factory) belong on the Manager rather than the QuerySet since they are not chainable filters. Being able to articulate the soft-delete trade-off, hidden global filter versus explicit vocabulary, is exactly the level mid-senior rounds test.
class JobQuerySet(models.QuerySet):
def active(self):
return self.filter(status='active', deadline__gte=timezone.now())
def in_city(self, name):
return self.filter(Q(city__iexact=name) | Q(is_remote=True))
def with_listing_relations(self):
return self.select_related('company').prefetch_related('skills')
class Job(models.Model):
...
objects = JobQuerySet.as_manager() # chainable vocabulary
# usage reads like the domain
jobs = Job.objects.active().in_city('Pune').with_listing_relations()
# soft delete: explicit managers, no hidden global filter
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True)
objects = models.Manager() # everything, default
alive = models.QuerySet.as_manager() # narrow in subclasses
class Meta:
abstract = True
Q30Django signals: how do post_save and friends work, what are their production pitfalls, and when should you not use them?
IntermediateArchitecture
Answer
Signals are synchronous in-process callbacks: pre_save/post_save, pre_delete/post_delete, m2m_changed, and request_started/request_finished. You connect a receiver with @receiver(post_save, sender=Application) and register it in AppConfig.ready() (importing a signals module there), passing dispatch_uid to prevent double registration when modules import twice. Despite the event-driven look, nothing is asynchronous or decoupled at runtime: receivers run inline in the caller's thread inside the same request, and an unhandled exception in a receiver propagates into whoever called save().
That drives the pitfall list interviewers expect. First, invisible control flow: save() acquiring side effects defined three apps away makes debugging archaeology; the stack trace shows the receiver but nothing explains why it exists. Second, bulk operations bypass them: queryset.update(), bulk_create() and bulk_update() do not call save() and fire no pre/post_save, so any invariant maintained only by signal silently corrupts under the first bulk backfill.
Third, ordering between multiple receivers is registration order, effectively undefined across apps. Fourth, the transaction trap: post_save fires before commit, so a receiver enqueuing Celery work races the commit unless wrapped in transaction.on_commit. Fifth, recursion: a receiver calling instance.save() re-fires post_save; guard with update_fields checks or save-less updates.
Legitimate uses are reacting to models you do not own (a profile row on user creation from django-allauth's models, cache invalidation on third-party model changes) and framework integration points. For your own models, an explicit service function, create_application() that saves, notifies and logs in visible sequence, is almost always clearer, testable and bulk-safe; the strongest interview answer states that preference and defends it with the update()/bulk bypass, then names on_commit as the mitigation when signals are unavoidable.
# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.db import transaction
@receiver(post_save, sender=Application,
dispatch_uid='application_created_notify')
def application_created(sender, instance, created, **kwargs):
if not created:
return
# fires only after the surrounding transaction commits
transaction.on_commit(
lambda: notify_recruiter.delay(instance.id))
# apps.py
class ApplicationsConfig(AppConfig):
name = 'applications'
def ready(self):
from . import signals # noqa: F401
# REMEMBER: none of these fire signals
Application.objects.filter(job=job).update(status='on_hold')
Application.objects.bulk_create(rows)
Q31In Django REST Framework, how do Serializer and ModelSerializer validation and representation actually flow, and how do you handle nested writes?
IntermediateDRF
Answer
A DRF serializer is a bidirectional converter. Inbound: serializer = ApplicationSerializer(data=request.data); is_valid(raise_exception=True) runs field-level to_internal_value and validators, then validate_<field>() hooks, then the object-level validate(self, attrs), mirroring Django forms; errors become a 400 with a field-keyed JSON body. Validated data lands in serializer.validated_data, and save() dispatches to create(validated_data) or update(instance, validated_data) depending on whether an instance was passed.
Outbound: serializer.data runs each field's to_representation, which you override for custom shaping. ModelSerializer introspects the model to generate fields plus default create/update, and importantly auto-generates validators from model constraints: unique=True becomes UniqueValidator, unique_together becomes UniqueTogetherValidator, which is why 'duplicate' errors appear at validation time with clean 400s rather than as IntegrityError 500s. Its Meta knobs: fields (be explicit; fields = '__all__' leaks new columns by default), read_only_fields, extra_kwargs for per-field options like write_only=True on passwords.
Useful field tools: source='company.name' for traversal, SerializerMethodField for computed read-only values, PrimaryKeyRelatedField with a queryset for writable relations (scope that queryset per-request in get_fields or via context to prevent IDOR-style cross-tenant references). Nested serializers render related objects inline but are read-only by default: DRF refuses writable nesting unless you override create()/update() to unpack the nested dicts and create related rows yourself, typically inside transaction.atomic. The performance link interviewers probe: every relation the serializer touches must be select_related/prefetch_related in the view's queryset, and SerializerMethodField hitting the ORM per object is the most common hidden N+1 in DRF codebases; many_init and bulk paths do not magically batch it.
from rest_framework import serializers
from django.db import transaction
class SkillSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = ['id', 'name']
class JobSerializer(serializers.ModelSerializer):
company_name = serializers.CharField(source='company.name',
read_only=True)
skills = SkillSerializer(many=True) # nested
class Meta:
model = Job
fields = ['id', 'title', 'salary_min', 'salary_max',
'company_name', 'skills']
def validate(self, attrs):
if attrs['salary_min'] > attrs['salary_max']:
raise serializers.ValidationError(
{'salary_min': 'Must not exceed salary_max.'})
return attrs
@transaction.atomic
def create(self, validated_data): # writable nesting is manual
skills = validated_data.pop('skills')
job = Job.objects.create(**validated_data)
for s in skills:
skill, _ = Skill.objects.get_or_create(name=s['name'])
job.skills.add(skill)
return job
Q32APIView, generic views and ViewSets in DRF: how do routers generate URLs, and where do permissions and get_serializer_class fit?
IntermediateDRF
Answer
DRF layers three abstraction levels. APIView is the base: you write get()/post() by hand and gain DRF's request parsing, content negotiation, authentication and exception handling over a plain Django view; right for bespoke endpoints (a webhook, a stats aggregate). Generic views (ListCreateAPIView, RetrieveUpdateDestroyAPIView and friends) compose GenericAPIView with mixins and reduce standard CRUD to declaring queryset and serializer_class; get_object() handles the 404 and calls check_object_permissions.
ViewSets go one further: a ModelViewSet bundles list/retrieve/create/update/partial_update/destroy as actions, and a Router (DefaultRouter) generates the URL patterns, router.register(r'jobs', JobViewSet, basename='job') yields /jobs/ and /jobs/{pk}/ plus a browsable API root. Non-CRUD operations attach with @action(detail=True, methods=['post']) creating /jobs/{pk}/publish/. The customisation hooks are the real interview content because that is where production logic lives: get_queryset() must scope rows to the requesting user or tenant (returning Job.objects.filter(company__members=self.request.user), never a bare .all(), the number one IDOR source in DRF codebases); get_serializer_class() switches shapes per action (a lightweight list serializer, a heavier detail one, a write serializer for create); get_permissions() varies permissions per action (AllowAny for list, IsAuthenticated for create, an owner check for destroy).
Permission classes short-circuit in order, with has_permission for view-level and has_object_permission for object-level checks; note list endpoints never call has_object_permission, filtering in get_queryset is the only row-level guard there. When to use which: ViewSets plus routers for the resource-shaped 80%, generics when you want CRUD without router conventions, APIView when the endpoint is not a resource at all. Mentioning that you keep business logic out of all three, in service functions the views call, marks the senior end of answers.
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
class JobViewSet(viewsets.ModelViewSet):
basename = 'job'
def get_queryset(self): # tenant scoping = security
return (Job.objects
.filter(company__members=self.request.user)
.select_related('company')
.prefetch_related('skills'))
def get_serializer_class(self):
if self.action == 'list':
return JobListSerializer # slim
if self.action in ('create', 'update', 'partial_update'):
return JobWriteSerializer
return JobDetailSerializer
def get_permissions(self):
if self.action == 'destroy':
return [IsCompanyAdmin()]
return [permissions.IsAuthenticated()]
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
job = self.get_object() # 404 + object perms
publish_job(job, by=request.user) # service layer
return Response({'status': 'published'})
# urls.py
router = DefaultRouter()
router.register(r'jobs', JobViewSet, basename='job')
Q33Compare SessionAuthentication, TokenAuthentication and JWT (simplejwt) in DRF, and how does throttling work alongside them?
IntermediateDRF
Answer
DRF walks authentication_classes in order until one succeeds, populating request.user and request.auth. SessionAuthentication rides Django's session cookie: right for the browsable API and same-origin frontends, and it enforces CSRF (the source of mysterious 403s when people call session-authed endpoints from fetch without the X-CSRFToken header). TokenAuthentication (rest_framework.authtoken) stores one opaque token per user in a database table, sent as 'Authorization: Token <key>'.
It is simple and revocable (delete the row) but has real limits: a database read on every request, one token per user so per-device logout is impossible without extending it, no expiry by default, and tokens stored in plaintext in the table. JWT via djangorestframework-simplejwt issues a short-lived access token and a longer refresh token from /api/token/; the access token is validated by signature alone, no database hit, which is why it suits mobile apps and horizontally scaled APIs, and cross-service verification works with RS256 public keys. The trade is revocation: a live access token cannot be recalled, so keep ACCESS_TOKEN_LIFETIME short (minutes, not days), enable ROTATE_REFRESH_TOKENS with BLACKLIST_AFTER_ROTATION (the token_blacklist app) so stolen refresh tokens die on first reuse, and never put sensitive claims in the payload, it is base64, not encrypted.
Throttling is orthogonal and runs after authentication: AnonRateThrottle keys on client IP, UserRateThrottle on user id, with rates like '100/hour' in DEFAULT_THROTTLE_RATES; ScopedRateThrottle gives per-endpoint budgets (an OTP-send endpoint at '5/hour' is the standard Indian-product example). Two production caveats close the answer well: DRF throttling stores counters in the cache backend, so with LocMemCache each gunicorn worker counts separately, you need Redis for throttles to mean anything; and behind a proxy, IP-keyed throttling requires correct X-Forwarded-For handling or you throttle the load balancer.
# settings.py
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '60/hour', 'user': '1000/hour', 'otp': '5/hour',
},
}
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
# per-endpoint budget
class SendOtpView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'otp'
Q34How do you use Django's cache framework with Redis: cache_page, template fragment caching and the low-level API, and how do you invalidate correctly?
IntermediateCaching
Answer
Django ships a first-party Redis backend since 4.0: django.core.cache.backends.redis.RedisCache in CACHES, no third-party package needed. There are three granularities. Per-view: @cache_page(300) stores the entire response body keyed by URL (including the query string) and honours Vary headers; it is the bluntest tool, wrong for any page containing user-specific content unless you vary on the right headers, and the classic incident is caching a page with a logged-in navbar and serving one user's name to everyone.
Template fragment: {% load cache %}{% cache 300 job_sidebar job.id %} caches just a rendered chunk, with extra arguments becoming part of the key, right for expensive fragments on otherwise dynamic pages. Low-level: cache.get/set/delete, cache.get_or_set(key, callable, timeout), cache.incr, and batched get_many/set_many, this is what real applications mostly use, caching computed values and serialised query results with explicit keys. Invalidation is the actual engineering.
Practical rules: prefer short TTLs over clever invalidation wherever staleness is tolerable; build keys from stable ids plus a version segment ('job:v3:{id}:detail') so a deploy that changes the shape bumps the version rather than flushing; delete keys in the write path or in transaction.on_commit so a rollback does not leave the cache ahead of the database; and never cache per-user data under a shared key (embed user id in the key or skip caching). Two failure modes worth naming unprompted: the stampede, a hot key expiring makes hundreds of workers recompute simultaneously (mitigate with staggered TTL jitter or a lock, get_or_set is not atomic across processes); and treating Redis as durable, evictions under memory pressure mean the cache can drop anything anytime, so code must always tolerate a miss. Also say that KEY_PREFIX separates environments sharing one Redis, avoiding the staging-flushes-production incident.
# settings.py
CACHES = {'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://redis:6379/0',
'KEY_PREFIX': 'gs-prod',
'TIMEOUT': 300,
}}
from django.core.cache import cache
from django.db import transaction
JOB_KEY = 'job:v2:{id}:card'
def job_card(job_id):
return cache.get_or_set(
JOB_KEY.format(id=job_id),
lambda: build_job_card(job_id), # runs only on miss
timeout=600)
def update_job(job, **fields):
for k, v in fields.items():
setattr(job, k, v)
job.save(update_fields=list(fields))
transaction.on_commit(
lambda: cache.delete(JOB_KEY.format(id=job.id)))
{% load cache %}
{% cache 300 job_sidebar job.id request.LANGUAGE_CODE %}
...expensive fragment...
{% endcache %}
Q35How do you integrate Celery with Django correctly: task discovery, transaction safety, idempotency, retries and beat scheduling?
IntermediateBackground Jobs
Answer
The wiring: a celery.py in the project package creates the Celery app, loads settings via app.config_from_object('django.conf:settings', namespace='CELERY'), and calls app.autodiscover_tasks() so every installed app's tasks.py is found; tasks are declared with @shared_task so app code never imports the Celery instance. Redis or RabbitMQ is the broker; workers run as separate processes (celery -A project worker), and celery beat is the scheduler process for periodic tasks. The correctness content interviewers actually want is the pitfall list.
Transaction safety first: calling task.delay(obj.id) inside transaction.atomic races the commit, the worker can start before the transaction commits and fail with DoesNotExist, or run against data that rolls back; always enqueue via transaction.on_commit. Second, pass primary keys, never model instances: instances get pickled stale, and JSON serialization (the default and correct setting) cannot carry them anyway; the task re-fetches fresh rows. Third, idempotency: with acks_late plus worker crashes, or visibility timeouts on Redis, a task can execute twice, so tasks must be safe to re-run, guard with state checks ('is this email already marked sent'), database constraints, or an idempotency key.
Fourth, retries: bind the task (bind=True) and use self.retry(exc=exc, countdown=...) with autoretry_for, retry_backoff and retry_jitter for transient failures, but cap max_retries and route permanent failures to logging rather than infinite retry. Operational notes that show production exposure: set explicit task time limits (task_time_limit/task_soft_time_limit) so a hung HTTP call cannot wedge a worker; use separate queues for latency-sensitive and bulk work (-Q emails,default) so a resume-parsing backlog does not delay OTP emails; monitor with Flower or Prometheus exporters; and remember beat needs a single instance (or a lock via django-celery-beat's DatabaseScheduler) or every replica fires the schedule.
# project/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
app = Celery('project')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# jobs/tasks.py
from celery import shared_task
@shared_task(bind=True, autoretry_for=(ConnectionError,),
retry_backoff=True, retry_jitter=True, max_retries=5,
soft_time_limit=60)
def send_offer_letter(self, application_id):
app = Application.objects.select_related('candidate').get(
pk=application_id)
if app.offer_sent_at: # idempotency guard
return 'already-sent'
deliver_email(app.candidate.email, render_offer(app))
app.offer_sent_at = timezone.now()
app.save(update_fields=['offer_sent_at'])
# enqueue AFTER commit, with the pk only
transaction.on_commit(lambda: send_offer_letter.delay(app.id))
Q36Testing Django applications: TestCase versus TransactionTestCase, setUpTestData, the test Client, pytest-django and assertNumQueries.
IntermediateTesting
Answer
Django's TestCase wraps every test method in a transaction rolled back afterwards, so tests are isolated without re-creating data, and wraps the whole class in a second transaction for setUpTestData(), which builds shared fixtures once per class instead of once per test, the single biggest and cheapest test-suite speedup; per-test mutation is safe because Django rolls instances back between tests. TransactionTestCase (and pytest-django's transactional_db) actually commits and truncates tables afterwards: slower, but required when the code under test uses transaction.on_commit (plain TestCase never commits, so callbacks never fire, though captureOnCommitCallbacks exists to assert on them), tests select_for_update contention, or exercises code in another thread. The test Client simulates requests through the full middleware and routing stack without a server: self.client.post('/api/jobs/', data, content_type='application/json'), client.force_login(user) to skip the login dance, and response.json() for API bodies; DRF's APIClient adds force_authenticate and format='json'. pytest-django is the de facto runner in Indian product companies: fixtures over setUp, @pytest.mark.django_db to grant database access, parametrize for case tables, and it honours --reuse-db/--keepdb so the schema is not rebuilt every run, which on a big migration history cuts minutes.
Factories beat fixtures files: factory_boy's DjangoModelFactory with Faker generates valid objects per test with overridable fields, avoiding brittle shared JSON fixtures. Two assertions show seniority: assertNumQueries(3) (or django_assert_num_queries in pytest) pins the query count on hot endpoints so an accidental N+1 fails CI rather than production; and settings-sensitive code is tested with override_settings. Round out with structure advice: mock at service boundaries (payment gateways, S3, email via django.core.mail.outbox) not at the ORM, keep unit tests off the database where possible, and run the suite with --parallel locally and in CI.
import pytest
from django.test import TestCase
class JobApiTests(TestCase):
@classmethod
def setUpTestData(cls): # once per class, not per test
cls.user = UserFactory()
cls.company = CompanyFactory(members=[cls.user])
cls.jobs = JobFactory.create_batch(5, company=cls.company)
def test_list_is_scoped_and_efficient(self):
self.client.force_login(self.user)
with self.assertNumQueries(3): # N+1 regression guard
resp = self.client.get('/api/jobs/')
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()['results']), 5)
def test_on_commit_task_enqueued(self):
with self.captureOnCommitCallbacks(execute=True) as callbacks:
apply_to_job(self.jobs[0], self.user)
self.assertEqual(len(callbacks), 1)
@pytest.mark.django_db
def test_close_expired(job_factory):
job = job_factory(deadline=yesterday(), status='active')
call_command('close_expired_jobs')
job.refresh_from_db()
assert job.status == 'closed'
Q37Data migrations with RunPython: why must you use apps.get_model() instead of importing models, and how do squashing, --fake and merge migrations work?
IntermediateMigrations
Answer
A data migration is an empty migration (makemigrations --empty app) carrying a RunPython operation whose function receives (apps, schema_editor). Inside it you must fetch models with apps.get_model('jobs', 'Job'), never import them, because the import gives you today's class while the migration runs at a historical point in the graph: the real model may have fields that do not exist yet at that migration's position (crashing the migration), or custom save() logic and signals that must not run against half-migrated data. apps.get_model returns a historical model reconstructed from migration state: correct fields for that point in history, no custom methods, no signals. Write the reverse function where feasible (or RunPython.noop) so migrate backwards works, and for large tables batch the work, iterator() with bulk_update in chunks, because a single UPDATE touching fifty million rows inside the migration transaction will hold locks for the duration; on very large tables the data backfill often should not be a migration at all but a management command run after deploy.
Squashing: squashmigrations app 0001 0040 generates one migration replacing forty, using the replaces attribute so databases that already applied the originals are unaffected while fresh databases run the squash; RunPython operations without elidable=True block optimisation and get carried along. --fake marks migrations applied without executing, for reconciling a database that was changed manually, and --fake-initial handles adopting pre-existing tables; misusing --fake to 'fix' errors leaves schema and history divergent, which surfaces weeks later as impossible-looking migration failures. Merge migrations resolve two branch heads (both branches added 0008): makemigrations --merge creates 0009 depending on both, which is routine and safe when the branches touched different things, and a prompt to actually think when they touched the same model.
# jobs/migrations/0042_backfill_slwritten by makemigrations --empty
from django.db import migrations
from django.utils.text import slugify
def forwards(apps, schema_editor):
Job = apps.get_model('jobs', 'Job') # historical model, no signals
batch = []
for job in Job.objects.filter(slug='').iterator(chunk_size=2000):
job.slug = f'{slugify(job.title)}-{job.pk}'
batch.append(job)
if len(batch) >= 2000:
Job.objects.bulk_update(batch, ['slug'])
batch = []
if batch:
Job.objects.bulk_update(batch, ['slug'])
class Migration(migrations.Migration):
dependencies = [('jobs', '0041_job_slug')]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop,
elidable=True),
]
# squash forty migrations into one:
# python manage.py squashmigrations jobs 0001 0040
Q38Inside class-based views: what do as_view() and dispatch() do, how does mixin MRO ordering work, and where do get_queryset and get_context_data fit?
IntermediateViews
Answer
as_view() is a classmethod returning a plain view function; per request that function instantiates the class (so self is request-scoped), stashes request/args/kwargs on it, and calls dispatch(). dispatch() looks at request.method, checks it against http_method_names, and routes to the matching lowercase method, get(), post(), delete(), returning 405 via http_method_not_allowed() for anything unimplemented. Everything else in generic CBVs is template-method hooks hanging off that spine. For ListView: get() calls get_queryset() (override this, not queryset =, whenever filtering depends on the request, because the class attribute is evaluated once at import), then paginates via paginate_by, then get_context_data() assembles the template context (call super() and add keys), then render_to_response with get_template_names().
For DetailView: get_object() resolves pk/slug from the URLconf against get_queryset(), raising 404. For form views (CreateView/UpdateView/FormView): get_form_class/get_form_kwargs feed the form, then form_valid(form) on success (default saves and redirects to get_success_url()) and form_invalid(form) re-renders with errors; injecting request.user onto the instance in form_valid before super() is the standard pattern. Mixins compose through Python's MRO, and order matters concretely: LoginRequiredMixin must precede the generic view in the bases list so its dispatch() runs first and can redirect before any queryset work; UserPassesTestMixin and PermissionRequiredMixin likewise.
Decorating CBVs uses @method_decorator(decorator, name='dispatch') at the class level. The interview trap in this area is state: setting mutable state as a class attribute shares it across every request in the process, whereas as_view(initkwargs) and per-request self attributes are safe; being able to explain exactly why one is a concurrency bug and the other is not demonstrates you understand what as_view() actually builds.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView
class ApplicationCreateView(LoginRequiredMixin, CreateView):
model = Application
form_class = ApplicationForm
template_name = 'applications/apply.html'
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['candidate'] = self.request.user # form needs the user
return kwargs
def form_valid(self, form):
form.instance.candidate = self.request.user
form.instance.job_id = self.kwargs['job_pk']
return super().form_valid(form) # saves + redirects
def get_success_url(self):
return reverse('jobs:detail',
kwargs={'pk': self.kwargs['job_pk']})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['job'] = get_object_or_404(Job, pk=self.kwargs['job_pk'])
return ctx
Q39Handling file uploads in production Django: FileField storage backends, django-storages with S3, private files and upload validation.
IntermediateFiles & Storage
Answer
FileField and ImageField store only a path string in the database; bytes go to the storage backend. upload_to can be a string pattern ('resumes/%Y/%m/') or, better, a callable receiving (instance, filename) that builds a collision-proof key, standard practice is discarding the client filename entirely in favour of a UUID plus a sanitised extension, since client names are attacker-controlled input (path characters, misleading extensions, absurd lengths). In development FileSystemStorage writes under MEDIA_ROOT; in production on containers or multiple servers, local disk is wrong (ephemeral, unshared), so the default storage becomes S3 via django-storages (storages.backends.s3.S3Storage), configured through the STORAGES setting. Access control is the part interviews dig into: a resume bucket must not be public, so default_acl private plus querystring_auth generates pre-signed URLs with expiry (AWS_QUERYSTRING_EXPIRE), and your view authorises the requester before handing out file.url; for extra control you proxy through a view that checks ownership then redirects to a fresh signed URL.
Never serve user uploads from your application domain unsanitised, an uploaded HTML file served same-origin is stored XSS, which is why upload validation matters: enforce FILE_UPLOAD_MAX_MEMORY_SIZE and DATA_UPLOAD_MAX_MEMORY_SIZE at the Django layer, cap sizes in a form/serializer validator, verify content type by sniffing magic bytes (python-magic) rather than trusting Content-Type, and constrain extensions with FileExtensionValidator. ImageField's Pillow verification catches non-images but is not a security scan. Two operational details: deleting a model row does not delete the stored file (Django removed auto-delete long ago), so orphan cleanup is either a signal or, better, an explicit lifecycle job; and large uploads should go direct-to-S3 with presigned POST from the browser, keeping multi-hundred-MB streams out of gunicorn workers entirely, with Django only recording the resulting key after an integrity callback.
import uuid
from django.core.validators import FileExtensionValidator
def resume_key(instance, filename):
ext = filename.rsplit('.', 1)[-1].lower()
return f'resumes/{instance.candidate_id}/{uuid.uuid4()}.{ext}'
class Resume(models.Model):
candidate = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
file = models.FileField(
upload_to=resume_key,
validators=[FileExtensionValidator(['pdf', 'docx'])])
# settings.py
STORAGES = {'default': {
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {
'bucket_name': 'gs-private-uploads',
'default_acl': 'private',
'querystring_auth': True, # pre-signed URLs
'querystring_expire': 600, # 10-minute links
},
}}
def download_resume(request, pk):
r = get_object_or_404(Resume, pk=pk)
if not can_view_resume(request.user, r):
raise PermissionDenied
return redirect(r.file.url) # fresh signed URL
Q40Beyond CSRF, what does Django protect automatically against XSS, SQL injection and clickjacking, and where do developers defeat those protections?
IntermediateSecurity
Answer
Django's posture is safe-by-default with explicit escape hatches, and interviews test whether you know both halves. SQL injection: the ORM always parameterises, filter(title=user_input) sends the value separately from the SQL, so injection through ORM values is not possible. Developers reintroduce it in exactly three places: raw() or cursor.execute() with f-string interpolation instead of the params argument, the deprecated-in-spirit extra() method, and unvalidated input reaching column or ordering names, order_by(request.GET['sort']) lets an attacker order by arbitrary expressions, so column names must be allow-listed since parameters cannot bind identifiers.
XSS: template autoescaping covers interpolated variables, and is defeated by |safe, {% autoescape off %}, mark_safe() on user-influenced strings, format_html misuse (its whole point is escaping arguments, mixing it with pre-concatenated strings loses that), and injecting user data into inline <script> blocks or attributes where HTML-escaping is the wrong encoding, JSON belongs in json_script filter output, not string-built JavaScript. Clickjacking: XFrameOptionsMiddleware sends X-Frame-Options: DENY by default; people break it site-wide to embed one page instead of using @xframe_options_exempt on that page. SecurityMiddleware adds HSTS, nosniff, and referrer policy per the settings from check --deploy.
Host header poisoning: ALLOWED_HOSTS validation, defeated by ['*'] pasted from Stack Overflow. Beyond the automatics, name the gaps Django does not cover so the interviewer hears completeness: no built-in rate limiting or brute-force lockout (django-axes or gateway-level), no output encoding help if you bypass templates and build HTML in Python, mass assignment guarded only by explicit fields lists in forms/serializers (never '__all__' on write paths), and object-level authorisation is entirely your job, the most common real vulnerability in Django codebases is an IDOR from a queryset that forgot to filter by owner, not an injection.
Key Points
- ORM parameterises values; injection returns via raw SQL f-strings and order_by on raw input
- Autoescaping handles XSS until |safe / mark_safe / inline-script interpolation
- X-Frame-Options DENY by default; exempt single pages, never the site
- Django has no rate limiting or object-level authz: IDOR via unscoped querysets is the top real-world hole
Q41Pagination in Django and DRF: Paginator internals, why COUNT(*) hurts at scale, and PageNumberPagination versus CursorPagination.
IntermediatePerformance
Answer
Django's Paginator slices a queryset per page, page 3 of 20 becomes LIMIT 20 OFFSET 40, and exposes page objects with has_next, num_pages and friends; ListView integrates it via paginate_by. Two independent costs bite at scale. First, the count: rendering 'Page 3 of 4,512' requires SELECT COUNT(*), which on PostgreSQL is a scan proportional to table size, on a hundred-million-row table that count can dominate the request.
Mitigations: drop the total (UI shows just next/prev), cache the count, or subclass Paginator to return PostgreSQL's reltuples estimate; the admin's show_full_result_count = False exists for exactly this. Second, deep offsets: OFFSET 200000 makes the database produce and discard two hundred thousand rows before returning twenty, so latency grows linearly with page depth, and crawlers walking ?page=9000 can degrade the whole database. That is why offset pagination is fine for shallow human browsing and wrong for feeds, exports and infinite scroll.
DRF wraps all this in pluggable classes: PageNumberPagination (?page=3, includes count), LimitOffsetPagination (?limit=20&offset=40, same deep-offset pathology), and CursorPagination, which is the scale-correct option: it returns an opaque cursor encoding the ordering-field value of the last row, and the next page queries WHERE created_at < <cursor-value> ORDER BY created_at DESC LIMIT 20, an index seek whose cost is constant regardless of depth. Requirements and trade-offs to state: CursorPagination needs an ordering field that is indexed and effectively unique-ish per row (append pk as a tiebreaker for non-unique timestamps or rows get skipped/duplicated at boundaries), users cannot jump to page N, and there is no total count, which product managers must be told up front. Set DEFAULT_PAGINATION_CLASS and PAGE_SIZE globally, and always enforce a max page size (max_page_size), an unbounded ?limit=100000 is a self-DoS endpoint.
from rest_framework.pagination import CursorPagination, PageNumberPagination
class JobFeedPagination(CursorPagination):
page_size = 20
max_page_size = 100
ordering = ('-created_at', '-id') # indexed, pk tiebreaker
class AdminSearchPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 100 # never trust client limits
class JobViewSet(viewsets.ReadOnlyModelViewSet):
pagination_class = JobFeedPagination
def get_queryset(self):
# matching composite index makes each page an index seek
return Job.objects.order_by('-created_at', '-id')
# model
class Job(models.Model):
class Meta:
indexes = [models.Index(fields=['-created_at', '-id'])]
Q42What is in the ORM query-optimisation toolkit besides select_related: only()/defer(), exists(), iterator(), bulk_create/bulk_update and explain()?
IntermediateORM Performance
Answer
only('id', 'title') and defer('description') control which columns the SELECT fetches while still returning model instances; the win is real when rows carry fat columns (long text, JSON blobs) that a listing never shows. The trap that makes teams ban them without understanding: touching a deferred field later triggers one extra query per instance, a self-inflicted N+1 that is worse than fetching the column up front, so only() is only correct when you are certain what the downstream code reads (values() is safer when you do not need instances). Existence checks: qs.exists() compiles to SELECT 1 ...
LIMIT 1 and beats count() > 0 (full count) and if qs: (loads all rows) whenever you will not iterate afterwards; conversely, if you will iterate, the truthiness check is free since it fills the cache you were going to fill anyway. count() versus len(): len(qs) evaluates and caches (right if you also need the objects), qs.count() does COUNT(*) (right if you need only the number, but remember big-table counts are scans). iterator(chunk_size=2000) streams rows via server-side cursors on PostgreSQL without populating the result cache, holding memory flat for million-row exports; it disallows prefetch_related on older Djangos (supported with chunk_size since 4.1) and forfeits the cache, so it is for one-pass processing. Write-side batching: bulk_create(objs, batch_size=1000, ignore_conflicts=...) and bulk_update(objs, fields) collapse thousands of INSERTs/UPDATEs into few statements; the costs, no save() calls, no signals, no auto_now, and (on older versions) unset pks with ignore_conflicts, must be listed alongside, as must update_or_create for single-row upserts. Finally qs.explain(analyze=True) prints the database plan, the difference between guessing and knowing whether an index was used; pair it with django-debug-toolbar in development and pg_stat_statements in production to find which query even matters before optimising anything.
# skinny listing rows, known access pattern
Job.objects.only('id', 'title', 'company_id').select_related(None)
# existence without loading
if Application.objects.filter(job=job, candidate=user).exists():
raise AlreadyApplied()
# flat-memory export of ~2M rows
for job in (Job.objects.values('id', 'title', 'city')
.iterator(chunk_size=5000)):
writer.writerow(job.values())
# batched writes: 10k rows, a handful of statements
Job.objects.bulk_create(new_jobs, batch_size=1000)
Job.objects.bulk_update(changed, ['status', 'deadline'],
batch_size=1000)
# NOTE: bulk_* skip save(), signals and auto_now fields
# ask the database what it will actually do
print(Job.objects.filter(city='Pune',
status='active').explain(analyze=True))
Q43How do Django's permission framework and groups work, and why does row-level authorisation need has_object_permission or explicit queryset scoping?
IntermediateAuthorization
Answer
django.contrib.auth auto-creates four model permissions per model (add/change/delete/view, e.g. 'jobs.change_job') and lets you declare custom ones in Meta.permissions, like ('publish_job', 'Can publish job'). Users get permissions directly or through Groups, which are just named permission bundles ('Recruiters', 'Moderators'), manageable from the admin so access changes need no deploy. Checks: user.has_perm('jobs.publish_job'), the @permission_required decorator, PermissionRequiredMixin on CBVs, and {% if perms.jobs.publish_job %} in templates; superusers pass every check automatically, which surprises people testing as admin.
The architectural limitation interviewers want named: these permissions are model-level, 'can change jobs', never 'can change THIS job'. Model-level checks alone are how IDORs ship: a recruiter with jobs.change_job passes has_perm for a competitor's job too. Row-level authorisation in Django is done one of three ways.
One, queryset scoping, the workhorse: every read path filters by ownership or tenancy (Job.objects.filter(company__members=user)), so unauthorised rows 404 rather than 403, which also avoids leaking existence. Two, per-object checks at the point of action: DRF's BasePermission.has_object_permission(request, view, obj) runs inside get_object() for detail routes (remember it never runs for list routes, scoping is the only guard there), or plain functions like can_edit(user, job) in service code. Three, when you genuinely need per-object grants stored as data, guest access to one specific record, django-guardian adds object permission tables, at real query-cost overhead, most products never need it. A strong closing observation: put the authorisation decision in exactly one place per resource (a scoped base queryset plus one permission class) because duplicated ad-hoc checks are how one path inevitably forgets, and add a test that user A literally cannot fetch user B's object id.
class Job(models.Model):
class Meta:
permissions = [('publish_job', 'Can publish job')]
# group setup (data, not code)
recruiters, _ = Group.objects.get_or_create(name='Recruiters')
recruiters.permissions.add(
Permission.objects.get(codename='publish_job'))
user.groups.add(recruiters)
# DRF: model perm + object ownership together
class IsCompanyMember(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.company.members.filter(pk=request.user.pk).exists()
class JobViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsCompanyMember]
def get_queryset(self): # list-route guard: scoping
return Job.objects.filter(company__members=self.request.user)
# the test that catches IDORs
def test_cannot_read_other_companys_job(api_client, other_job):
resp = api_client.get(f'/api/jobs/{other_job.pk}/')
assert resp.status_code == 404
Q44How do you write custom template filters, simple_tag and inclusion_tag, and when is a template tag the wrong place for logic?
IntermediateTemplates
Answer
Custom tags live in a templatetags package inside an app (with __init__.py); the module name is what {% load %} takes, and each module needs register = template.Library(). Filters are functions of one value plus an optional argument, registered with @register.filter, and used as {{ ctc|lakhs }}; mark them is_safe or use stringfilter as appropriate, and remember a filter returning HTML must escape its inputs itself (build output with format_html, never string concatenation, or you have written an XSS helper). @register.simple_tag registers a function callable as {% funnel_stage job user %} that returns a value; takes_context=True gives it the template context, and 'as varname' syntax stores the result. Django 5.2 added @register.simple_block_tag for tags wrapping content. @register.inclusion_tag('jobs/_card.html') is the component workhorse: the function returns a context dict and Django renders the named sub-template, giving you reusable, parameterised fragments, the closest DTL gets to components, and the right home for 'render this job card everywhere'.
After mechanics, interviewers usually probe judgement: template tags execute per render with no visibility in profiling tools people usually watch, so a tag that queries the database (Job.objects.filter(...) inside an inclusion tag rendered in a 50-item loop) is an N+1 hidden from the view layer entirely, the worst place to hide one. The discipline: tags format and arrange data the view already fetched; anything hitting the ORM belongs in the view or a prefetch, anything resembling business rules belongs in models or services where it can be unit-tested without a template render. Also mention the built-in escape hatches before writing a custom tag at all: the |date, |floatformat, |intcomma (humanize) family covers most formatting, and Django 5.1's {% querystring %} tag killed the most commonly hand-written custom tag, the pagination-link query-string preserver.
# jobs/templatetags/job_extras.py
from django import template
from django.utils.html import format_html
register = template.Library()
@register.filter
def lakhs(value):
"""1500000 -> '15 LPA'"""
try:
return f'{int(value) / 100000:g} LPA'
except (TypeError, ValueError):
return ''
@register.simple_tag(takes_context=True)
def active_class(context, url_name):
match = context['request'].resolver_match
return 'active' if match and match.url_name == url_name else ''
@register.inclusion_tag('jobs/_card.html')
def job_card(job, show_salary=True):
return {'job': job, 'show_salary': show_salary}
{# usage #}
{% load job_extras %}
<li class="{% active_class 'jobs:list' %}">Jobs</li>
{% for job in jobs %}{% job_card job show_salary=False %}{% endfor %}
Q45How do you structure Django settings across local, staging and production: environment variables, split settings modules and secret handling?
IntermediateConfiguration
Answer
The twelve-factor rule is that code is identical across environments and only configuration differs, delivered through the environment. In Django that means one of two mainstream layouts. Split modules: a settings/ package with base.py holding everything common, then thin local.py/production.py doing from .base import * and overriding, selected via DJANGO_SETTINGS_MODULE (manage.py defaults to local, the gunicorn unit sets production).
Or a single settings.py where every environment-varying value reads from the environment, with django-environ or pydantic-settings handling parsing: env.bool('DEBUG', default=False), env.db('DATABASE_URL') expanding a postgres:// URL into the DATABASES dict, env.list('ALLOWED_HOSTS'). Most teams combine both: split modules for structural differences (DEBUG toolbar installed locally, S3 storage in production), env vars for values and secrets. The rules that matter in review: secrets (SECRET_KEY, database passwords, Razorpay keys, AWS credentials) never appear in the repo, not in settings files, not in committed .env files, .env exists locally and is gitignored with a .env.example documenting keys; production pulls from a secret manager (AWS Secrets Manager, SSM Parameter Store, Infisical, Vault) injected as environment variables at deploy; and settings must fail fast, env('SECRET_KEY') with no default should raise at boot rather than booting with a placeholder, because a misconfigured container that starts is worse than one that crashes.
Anti-patterns to name: if DEBUG: branches encoding business behaviour (use explicit feature flags so staging can mirror production); from .base import * followed by surgical mutation of nested dicts like DATABASES['default']['OPTIONS'], which breaks silently when base restructures; and a settings_local.py imported at the bottom with try/except ImportError, which hides typos in overrides. Verification closes the loop: manage.py diffsettings against a booted environment, and check --deploy in the pipeline.
# settings/base.py
import environ
env = environ.Env()
SECRET_KEY = env('DJANGO_SECRET_KEY') # no default: fail fast
DEBUG = env.bool('DJANGO_DEBUG', default=False)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[])
DATABASES = {'default': env.db('DATABASE_URL')}
CACHES = {'default': env.cache('REDIS_URL')}
# settings/local.py
from .base import * # noqa
DEBUG = True
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware')
# settings/production.py
from .base import * # noqa
SECURE_SSL_REDIRECT = True
STORAGES = {'default': {'BACKEND': 'storages.backends.s3.S3Storage'}}
# gunicorn systemd unit / Dockerfile
# Environment=DJANGO_SETTINGS_MODULE=project.settings.production
# .env.example (committed), .env (gitignored)
Q46Async Django in practice: async views, the async ORM interface (aget, async for), sync_to_async, and when async actually pays off.
IntermediateAsync
Answer
Django has supported async def views since 3.1, served natively under ASGI (uvicorn/daphne via asgi.py); under WSGI they still work but Django spins an event loop per request, so the benefit requires an ASGI deployment. Since 4.1 the ORM exposes an async interface: await Job.objects.aget(pk=1), afirst(), acount(), aexists(), asave(), adelete(), abulk_create(), and async for job in queryset. The honest detail that distinguishes real understanding: those a-methods largely wrap the sync ORM in a thread executor rather than performing native async I/O all the way to the driver, so the ORM does not get faster, what you gain is that the event loop is not blocked while database work happens, keeping concurrency benefits for everything else on the loop.
The genuine wins for async views are I/O fan-out and slow-client workloads: calling three external HTTP APIs concurrently with httpx and asyncio.gather (a payments status check, a WhatsApp send, an enrichment API) collapses sequential latency into the slowest single call; long-polling, SSE, and many concurrent connections that mostly wait. CPU-bound work gains nothing and should not be async, and a view that just does two ORM queries and renders is better left sync. Mixing worlds has strict rules: calling sync code (ORM without a-methods, anything touching the database) directly inside an async view raises SynchronousOnlyOperation; wrap with asgiref.sync.sync_to_async, and the reverse bridge async_to_sync exists for calling async code from sync.
Each bridge hop costs a thread switch, so a 'fully async' view chaining five sync_to_async calls can be slower than the plain sync version, measure. Middleware matters too: each transition between sync and async middleware in the stack adds adaptation overhead, so an async view behind mostly sync middleware loses part of its point. Recent versions keep extending native async coverage (async auth functions like aauthenticate/alogin exist now), so state that the direction is real but you choose async per-endpoint by workload, not as a fashion.
import asyncio, httpx
from asgiref.sync import sync_to_async
from django.http import JsonResponse
async def candidate_summary(request, pk):
candidate = await Candidate.objects.select_related('profile').aget(pk=pk)
async with httpx.AsyncClient(timeout=5) as client:
gh, score = await asyncio.gather( # concurrent I/O fan-out
client.get(f'https://api.github.com/users/{candidate.github}'),
client.get(f'https://scores.internal/v1/{pk}'),
)
# legacy sync helper: bridge explicitly, do not call directly
badges = await sync_to_async(compute_badges)(candidate)
apps = [a async for a in
Application.objects.filter(candidate=candidate)
.values('job__title', 'status')]
return JsonResponse({'github': gh.json().get('public_repos'),
'score': score.json(), 'badges': badges,
'applications': apps})
# run under ASGI: uvicorn project.asgi:application --workers 4
Q47When is raw SQL justified in Django, and what do Manager.raw(), connection.cursor() and the params argument each require to stay safe?
IntermediateORM
Answer
The ORM covers the overwhelming majority of application queries, and staying in it preserves composability, database portability and automatic parameterisation. Raw SQL earns its place at known edges: window functions or CTE shapes awkward to express (though the ORM's Window, Subquery and, since Django 4.2, the ability to filter on window annotations shrink this space), database-specific features (PostgreSQL recursive CTEs, LATERAL joins, advisory locks, ON CONFLICT variants beyond what bulk_create exposes), hand-tuned reporting queries where you must control the exact plan, and hot paths where ORM overhead measurably matters after profiling. Two mechanisms exist.
Manager.raw('SELECT ... FROM jobs_job WHERE ...', params=[...]) maps result rows onto model instances: it must include the primary key column, supports deferred loading of omitted columns (with per-access query cost), and returns a RawQuerySet that is lazy but not chainable, no further .filter(). connection.cursor() drops to DB-API level for statements that return no model rows (aggregated reports, UPDATE ... RETURNING, DDL) with cursor.execute(sql, params) and fetchall/fetchone; dictfetchall is a documented recipe.
The safety rule is absolute and interviewers listen for the exact phrasing: values go through the params argument as placeholders (%s for both psycopg and mysqlclient), never through f-strings or .format(), because parameter binding keeps data out of the SQL parse step entirely. And since placeholders cannot bind identifiers, any table, column, or ORDER BY direction that varies must come from a hardcoded allow-list, not from request input. Operational hygiene completes a strong answer: keep raw SQL in one repository layer (a queries.py or repository module) so reviews and schema migrations can find it (a renamed column breaks raw strings silently until runtime), add tests pinning the row shape, and remember raw() bypasses model save logic and signals only on the read side, whereas cursor-level writes bypass everything including auto_now, updated_at maintenance moves into the SQL itself.
from django.db import connection
# raw() -> model instances; pk column mandatory; params ALWAYS bound
jobs = Job.objects.raw(
'SELECT id, title, salary_max FROM jobs_job '
'WHERE city = %s AND salary_max >= %s '
'ORDER BY salary_max DESC LIMIT 50',
params=['Bengaluru', 15_00_000])
# identifiers cannot be parameters: allow-list them
SORTABLE = {'created_at': 'created_at', 'ctc': 'salary_max'}
col = SORTABLE.get(request.GET.get('sort'), 'created_at')
rows = Job.objects.raw(
f'SELECT id, title FROM jobs_job ORDER BY {col} DESC', # from map
)
# cursor for non-model results
with connection.cursor() as cur:
cur.execute(
'SELECT city, COUNT(*), AVG(salary_max) FROM jobs_job '
'WHERE created_at >= %s GROUP BY city', [month_start])
report = cur.fetchall()
Q48Abstract base classes, multi-table inheritance and proxy models in Django: how does each map to tables, and which should you usually pick?
IntermediateModels
Answer
Django offers three inheritance modes with very different SQL consequences. Abstract base classes (Meta.abstract = True) create no table: children copy the fields into their own tables at migration time. This is the workhorse, TimeStampedModel with created_at/updated_at, a SoftDeleteModel, shared behaviour mixins, zero query cost because there is nothing to join, the only limits being that you cannot query the abstract base itself and FK related_names colliding across children need the '%(class)s' placeholder.
Multi-table inheritance (MTI) happens when you subclass a concrete model: each level gets its own table linked by an implicit OneToOneField (parent_ptr), and every access to inherited fields is a JOIN. It looks elegant for 'InternshipJob is a Job' hierarchies, but the costs surface fast: querying the parent returns parent instances with no cheap way to know which subtype each row is (discovering subtypes means trying each child accessor or joining all child tables, the classic polymorphism headache that django-polymorphic papers over with further joins), writes touch two tables, and cascade behaviour through parent_ptr surprises people. Most experienced Django teams treat MTI as a last resort, preferring either single-table designs with a type field and nullable specifics, or explicit OneToOne composition where the relationship is visible and opt-in per query.
Proxy models (Meta.proxy = True) create no table either: they reattach behaviour to an existing table, a different default manager, ordering, methods, or a second ModelAdmin registration (the standard trick for giving one table two admin views, 'Active Jobs' versus 'Archived Jobs'). They cannot add fields. The interview-ready summary: abstract for shared fields/behaviour (default), proxy for alternate behaviour over the same rows, MTI only when you truly need both types queryable as the parent and accept a JOIN per access, and be ready to sketch the tables each generates, because that is the follow-up.
# abstract: fields copied into each child table, no JOINs
class TimeStamped(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Job(TimeStamped):
title = models.CharField(max_length=120)
# MTI: jobs_internshipjob has job_ptr_id OneToOne PK -> jobs_job
class InternshipJob(Job):
stipend = models.IntegerField()
months = models.PositiveSmallIntegerField()
# InternshipJob.objects.get(...) => JOIN jobs_job ON job_ptr_id
# proxy: same table, different behaviour
class ArchivedJob(Job):
class Meta:
proxy = True
ordering = ['-updated_at']
objects = ArchivedManager() # filters status='archived'
@admin.register(ArchivedJob) # second admin over one table
class ArchivedJobAdmin(admin.ModelAdmin):
list_display = ('title', 'updated_at')
Q49Deploying Django with gunicorn and nginx: worker types and counts, WSGI versus ASGI, timeouts, and where each layer can fail.
AdvancedDeployment
Answer
The canonical stack is nginx in front (TLS termination, static file serving from STATIC_ROOT, request buffering, body-size limits) reverse-proxying to gunicorn running your WSGI application, with X-Forwarded-Proto forwarded and SECURE_PROXY_SSL_HEADER set so Django knows requests were HTTPS. Worker math is where interviews go first: gunicorn's default sync worker handles exactly one request at a time, so worker count bounds concurrency; the classic starting point is 2-4 workers per CPU core, tuned by observed memory (each worker is a full Python process holding Django plus your libraries, commonly 150-400MB) and by whether the workload is CPU- or I/O-heavy. For I/O-heavy request profiles, threaded workers (--threads) or gevent workers multiply concurrency per process, with the caveats that threads share the GIL (fine for I/O waits) and gevent monkey-patching must be verified against your database driver.
Fully async code paths need ASGI: uvicorn workers (or gunicorn with the uvicorn worker class) serving project.asgi:application; a mixed codebase can run ASGI and route sync views through the handler safely. Timeout layering causes real outages when misaligned: gunicorn --timeout kills a worker mid-request (default 30s), so it must exceed your slowest legitimate request but stay below nginx's proxy_read_timeout, and both should exceed your database statement_timeout so the query dies before the worker does, the failure signature of getting this wrong is WORKER TIMEOUT lines in gunicorn logs paired with 502s at nginx and orphaned queries still running in PostgreSQL. Other production levers that show experience: --max-requests with --max-requests-jitter recycles workers to contain slow memory leaks; --preload shares memory via copy-on-write but breaks per-worker database connection setup unless connections are created post-fork; readiness/liveness endpoints must avoid heavy dependencies or a database blip cascades into a full pod restart storm; and client_max_body_size in nginx must agree with DATA_UPLOAD_MAX_MEMORY_SIZE or uploads fail at different layers with different errors.
# gunicorn.conf.py
import multiprocessing
bind = '0.0.0.0:8000'
workers = multiprocessing.cpu_count() * 2 + 1
threads = 2 # I/O-heavy: cheap concurrency
timeout = 60 # > slowest legit request
graceful_timeout = 30
max_requests = 1000 # recycle: contain slow leaks
max_requests_jitter = 100 # avoid synchronized restarts
accesslog = '-'
# systemd / Docker CMD
# gunicorn project.wsgi:application -c gunicorn.conf.py
# async stack instead:
# gunicorn project.asgi:application -k uvicorn.workers.UvicornWorker
# nginx (alignment matters)
# location /static/ { alias /srv/static/; expires 30d; }
# location / {
# proxy_pass http://127.0.0.1:8000;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_read_timeout 65s; # > gunicorn timeout
# client_max_body_size 20m;
# }
Q50Database connections in Django at scale: CONN_MAX_AGE, the Django 5.1 native connection pool, PgBouncer modes, and diagnosing 'too many connections'.
AdvancedDatabase
Answer
Django's historical model is a connection per worker thread per database, opened on first query and closed at request end. CONN_MAX_AGE=60 (or None for unbounded) makes connections persistent across requests, eliminating reconnect latency, but it is persistence, not pooling: every gunicorn worker/thread still holds its own connection, so 8 pods x 5 workers x 2 threads = 80 connections idling against PostgreSQL's max_connections (commonly 100-200, and each costs server memory). Add Celery workers and cron jobs and 'FATAL: sorry, too many clients already' appears at peak, the classic Django scaling incident.
Django 5.1 added first-party pooling for PostgreSQL on psycopg 3: OPTIONS = {'pool': {'min_size': 2, 'max_size': 10}} gives each process an internal pool so threads/coroutines share connections; it is mutually exclusive with CONN_MAX_AGE (persistent connections must be off) and pools per process, so total = pool size x process count still needs arithmetic. At fleet scale the standard answer remains PgBouncer between everything and PostgreSQL. Its modes matter: session pooling barely helps; transaction pooling (the useful one) assigns a server connection only for a transaction's duration, multiplexing thousands of client connections over tens of server ones, but it forbids session state, no session-level advisory locks, no named prepared statements (set the driver's prepare_threshold accordingly, and older setups set DISABLE_SERVER_SIDE_CURSORS=True because queryset.iterator()'s server-side cursors break under transaction pooling).
Diagnosis workflow worth reciting: pg_stat_activity grouped by state and application_name shows who holds what; many 'idle in transaction' rows mean code holding transactions across slow work (long atomic blocks, external HTTP inside atomic), fixed in code, not by raising limits; set idle_in_transaction_session_timeout and statement_timeout as guardrails; and long-running management commands and Celery tasks should call close_old_connections() or rely on task lifecycle hooks so stale connections do not accumulate. Being able to compute the connection budget for a described deployment is a common staff-level whiteboard exercise.
# Django 5.1+ native pool (psycopg 3, PostgreSQL)
DATABASES = {'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'goodspace',
'OPTIONS': {
'pool': {'min_size': 2, 'max_size': 10, 'timeout': 10},
},
# NOTE: CONN_MAX_AGE must remain 0 with pooling
}}
# PgBouncer transaction-mode target instead:
# DATABASES['default'].update({
# 'HOST': 'pgbouncer', 'PORT': 6432,
# 'DISABLE_SERVER_SIDE_CURSORS': True,
# })
-- diagnosing exhaustion
SELECT state, application_name, count(*)
FROM pg_stat_activity GROUP BY 1, 2 ORDER BY 3 DESC;
SELECT pid, now() - xact_start AS txn_age, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY txn_age DESC LIMIT 10;
-- guardrails
ALTER DATABASE goodspace SET idle_in_transaction_session_timeout = '60s';
ALTER DATABASE goodspace SET statement_timeout = '30s';
Q51Concurrency bugs in Django: why get_or_create can raise IntegrityError, F() versus select_for_update versus optimistic locking, and the skip_locked work-queue pattern.
AdvancedConcurrency
Answer
Every Django race reduces to read-then-decide-then-write across concurrent workers. get_or_create is the textbook case: it SELECTs, misses, then INSERTs; two requests interleaving both miss and both insert. Without a unique constraint you get silent duplicates; with one, the loser gets IntegrityError, which is the better outcome, the constraint is the real guarantee, and the standard pattern catches IntegrityError and re-fetches (get_or_create itself does this internally when the lookup fields match the constraint, but custom lookup/defaults splits reopen the window). So rule one: uniqueness is enforced by UniqueConstraint in the database, application checks are advisory.
For numeric read-modify-write, F() expressions push the arithmetic into the UPDATE, making it atomic per row with zero locking held across application code, always the first choice for counters, balances and quota decrements, combined with a conditional filter (.filter(balance__gte=amount).update(balance=F('balance') - amount)) whose rows-affected return value tells you whether the debit happened. When the decision logic is too rich for one UPDATE, pessimistic locking: select_for_update() inside transaction.atomic serialises workers on the row; use nowait=True to fail fast instead of queueing, keep the critical section tiny, and lock parents before children in a consistent order to avoid deadlocks (Django surfaces them as OperationalError, retry the transaction). Optimistic locking suits low-contention human editing: carry a version integer, and UPDATE ...
WHERE pk=%s AND version=%s incrementing version; zero rows affected means someone else won, surface a conflict to the user, cheaper than locks and works across web requests where holding a transaction is impossible. Finally the work-queue idiom every senior Django engineer should produce on demand: select_for_update(skip_locked=True) with a LIMIT lets N workers each claim distinct pending rows with no coordination service, PostgreSQL hands each worker rows the others have locked past, the standard implementation of 'process pending applications' pollers and lightweight job queues.
from django.db import IntegrityError, transaction
from django.db.models import F
# 1) constraint is the guarantee; handle the race explicitly
try:
app, created = Application.objects.get_or_create(
job=job, candidate=user, defaults={'source': 'search'})
except IntegrityError:
app = Application.objects.get(job=job, candidate=user)
# 2) atomic conditional debit: no lock held in app code
debited = (Wallet.objects
.filter(user=user, balance__gte=cost)
.update(balance=F('balance') - cost))
if not debited:
raise InsufficientBalance()
# 3) work queue: N pollers, no double-claim
@transaction.atomic
def claim_batch(worker_id, n=10):
rows = (Task.objects.select_for_update(skip_locked=True)
.filter(status='pending').order_by('created_at')[:n])
ids = [t.pk for t in rows]
Task.objects.filter(pk__in=ids).update(
status='processing', claimed_by=worker_id)
return ids
Q52Zero-downtime Django migrations on PostgreSQL: deploy ordering, adding NOT NULL columns safely, dropping fields, and AddIndexConcurrently.
AdvancedMigrations
Answer
During a rolling deploy, old and new application code run simultaneously against one schema, so every migration must be compatible with both versions: expand-migrate-contract. Additive changes go first and stay nullable or defaulted; code that writes the new column ships next; only after no running code depends on the old shape does a later deploy contract (drop columns, add strictness). Concrete recipes interviewers expect.
Adding a NOT NULL column to a big table: naive AddField with a default used to rewrite the entire table under an ACCESS EXCLUSIVE lock; PostgreSQL 11+ made adding a column with a constant default metadata-only, and Django's db_default (5.0) expresses a database-level default cleanly, but the safe sequence on very large or pre-11 tables remains: add nullable, backfill in batches outside one giant transaction, add the NOT NULL constraint (Postgres validates with a scan, or use a NOT VALID check constraint then VALIDATE CONSTRAINT to keep locks short). Dropping a field is the inverted-order trap: remove all code references first and deploy, then migrate RemoveField later; done the other way, still-running old code SELECTs the dropped column and 500s, and because Django lists all columns explicitly in queries, even unused-by-you fields matter. Renames are effectively add-copy-drop across three deploys; a straight RENAME breaks the old code immediately.
Indexes on live tables must use CREATE INDEX CONCURRENTLY, which cannot run in a transaction: Django provides AddIndexConcurrently/RemoveIndexConcurrently in django.contrib.postgres.operations, in a migration marked atomic = False. Every migration should be checked for its lock: sqlmigrate before review, statement_timeout and lock_timeout set so a blocked ALTER TABLE gives up instead of queueing behind a long transaction while every new query queues behind it, that pileup, not the ALTER itself, is what takes sites down. Tooling exists to enforce this in CI (django-pg-zero-downtime-migrations, squawk linting), and separating 'deploy code' from 'run migrations' as pipeline stages, with migrations always backward-compatible one version, is the process answer that ties it together.
# Safe NOT NULL on a large table: three steps, three deploys
# Deploy 1: nullable column
migrations.AddField('job', 'search_rank',
models.IntegerField(null=True))
# Between deploys: batched backfill (management command, NOT one txn)
# Job.objects.filter(search_rank__isnull=True)[:5000] ... repeat
# Deploy 2: enforce, with db_default for new rows
migrations.AlterField('job', 'search_rank',
models.IntegerField(db_default=0))
# Concurrent index: no write-blocking, must be non-atomic
from django.contrib.postgres.operations import AddIndexConcurrently
class Migration(migrations.Migration):
atomic = False # required for CONCURRENTLY
dependencies = [('jobs', '0057_job_search_rank')]
operations = [
AddIndexConcurrently('job',
models.Index(fields=['-search_rank'],
name='job_rank_idx')),
]
# Always: python manage.py sqlmigrate jobs 0058 (review the locks)
Q53Scaling reads with database routers in Django: DATABASE_ROUTERS, replica lag, read-your-own-writes, and when using('replica') beats a global router.
AdvancedScaling
Answer
Once one PostgreSQL primary saturates on reads, Django's multi-database support routes traffic: DATABASES gains 'default' (primary) and one or more replica aliases, and a router class listed in DATABASE_ROUTERS implements db_for_read (return a replica, randomly among several for spread), db_for_write (always primary), allow_relation and allow_migrate (migrations run only against the primary; replication copies schema). The naive 'all reads to replica' router then meets physics: replication is asynchronous, so a replica trails the primary by milliseconds normally and by seconds under load or vacuum activity, and the bug class it creates is read-your-own-writes violations, a candidate submits an application (write to primary), the next request lists applications (read from lagging replica), and their submission is missing, an intermittent, user-visible, support-ticket-generating bug. Mitigations in practice: pin reads to the primary for a short window after a user writes (store a 'wrote recently' timestamp in session or a cookie and have the router or middleware honour it); route within-request reads-after-writes to the primary (some teams track 'this request has written' in thread-local/contextvar state inside the router); keep transactional flows entirely on the primary, since a transaction cannot span databases and select_for_update on a replica is meaningless; and monitor lag (pg_stat_replication / pg_last_wal_replay_lsn deltas) with alerting, because silent lag growth turns 'eventually consistent' into 'minutes stale'.
The explicit alternative scales better organisationally: skip the clever global router and use queryset.using('replica') (or a read-only manager) only on the specific heavy read paths that tolerate staleness, search listings, analytics dashboards, exports, sitemap generation, leaving everything else on the primary by default. That inverts the risk: staleness becomes opt-in per query, chosen by someone looking at that query. Interviewers often close asking what else you would try before replicas; the credible list is caching hot reads in Redis, fixing N+1 and missing indexes, and moving heavy reporting to a separate path, replicas are for genuine read volume, not for unoptimised queries.
# settings.py
DATABASES = {
'default': env.db('DATABASE_URL'), # primary
'replica1': env.db('REPLICA1_URL'),
}
DATABASE_ROUTERS = ['core.dbrouter.PrimaryReplicaRouter']
# core/dbrouter.py
import random
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
return random.choice(['replica1']) # spread across replicas
def db_for_write(self, model, **hints):
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True # same data, replicated
def allow_migrate(self, db, app_label, **hints):
return db == 'default' # migrate primary only
# explicit, staleness-tolerant paths only:
Job.objects.using('replica1').filter(status='active')
# read-your-writes: after a user write, pin to primary briefly
request.session['pin_primary_until'] = time.time() + 5
Q54Django Channels for WebSockets: consumers, channel layers on Redis, groups, and when you should use SSE or polling instead.
AdvancedReal-time
Answer
Channels extends Django beyond request-response by embracing ASGI fully: WebSocket connections are handled by consumers, class-based handlers (AsyncJsonWebsocketConsumer being the everyday base) with connect/receive_json/disconnect lifecycle methods, routed by a ProtocolTypeRouter in asgi.py that sends 'http' to the normal Django app and 'websocket' through AuthMiddlewareStack (session/auth available on scope['user']) to URLRouter patterns. The piece that makes it production-real is the channel layer: consumers run in whichever process holds the socket, so cross-process messaging (a Celery worker or a view notifying a connected user) goes through channels_redis, and groups implement pub/sub, group_add('recruiter_42', channel_name) on connect, then group_send from anywhere fans out to every socket in the group. In a view or task (sync code), you reach the layer via get_channel_layer() and async_to_sync(layer.group_send)(...).
The operational realities that separate demo from production: deployment changes shape (uvicorn/daphne ASGI processes, and your load balancer must support long-lived upgraded connections with sane idle timeouts, ALB and nginx both need explicit configuration); capacity planning counts concurrent open sockets per process rather than requests per second; authentication happens at connect and does not automatically re-check later, so permission revocation needs explicit group discipline (kick by closing, or key groups so you can stop sending); channel layers are at-most-once delivery with capacity limits, messages drop under backpressure, so anything requiring guaranteed delivery keeps state in the database with WebSocket as a wake-up hint, clients reconcile on reconnect; and scaling group fan-out across many Redis-backed processes has real limits worth acknowledging. The judgement question is when NOT to use it: notifications-style features where updates flow one way and a two-second delay is fine are simpler as SSE (a StreamingHttpResponse from an async view, plain HTTP, proxies happy, auto-reconnect built into EventSource) or short polling against a cheap endpoint. Channels earns its complexity for genuinely bidirectional, low-latency features: chat, collaborative editing, live interview rooms.
# consumers.py
from channels.generic.websocket import AsyncJsonWebsocketConsumer
class RecruiterInbox(AsyncJsonWebsocketConsumer):
async def connect(self):
user = self.scope['user']
if not user.is_authenticated:
await self.close(code=4401)
return
self.group = f'recruiter_{user.pk}'
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
async def disconnect(self, code):
await self.channel_layer.group_discard(self.group,
self.channel_name)
async def application_event(self, event): # type -> method
await self.send_json(event['payload'])
# from a Celery task / view (sync land)
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
async_to_sync(get_channel_layer().group_send)(
f'recruiter_{job.owner_id}',
{'type': 'application.event',
'payload': {'kind': 'new_application', 'job': job.id}})
# settings.py
CHANNEL_LAYERS = {'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {'hosts': ['redis://redis:6379/2']}}}
Q55Multi-tenant architecture in Django: shared schema with tenant scoping versus schema-per-tenant (django-tenants) versus database-per-tenant, and the failure modes of each.
AdvancedArchitecture
Answer
Three isolation levels, three cost curves. Shared schema (row-level) is the default for SaaS at Indian scale: every tenant-owned table carries a tenant ForeignKey, resolution middleware identifies the tenant per request (subdomain, header, or the user's org membership) and stashes it in a contextvar, and every query filters by it. The discipline problem is that one forgotten filter leaks data across paying customers, so mature codebases centralise scoping instead of trusting call sites: a TenantManager whose get_queryset() applies the contextvar filter, DRF base classes that scope get_queryset() and inject tenant on create, composite indexes leading with tenant_id so per-tenant queries stay fast, and cross-tenant leak tests in CI (fetch tenant B's object id as tenant A, expect 404).
PostgreSQL row-level security policies underneath add a database-enforced backstop some fintech audits require. Strengths: one database to operate, trivial onboarding, cross-tenant analytics is a query. Weaknesses: noisy neighbours share everything, per-tenant backup/restore is surgery, and 'delete this tenant's data completely' is a project.
Schema-per-tenant, packaged by django-tenants, gives each tenant a PostgreSQL schema with identical tables plus a shared public schema; the middleware sets search_path per request, so application code needs no tenant filters at all. Isolation and per-tenant operations improve dramatically, but migrations now fan out across every schema (migrate_schemas, N times the duration and N chances to fail mid-fleet, leaving schemas at different versions), thousands of schemas strain pg_catalog and tooling, and connection pooling interacts with search_path state (PgBouncer transaction mode requires care). Database-per-tenant is the enterprise-contract end: strongest isolation, per-tenant scaling and residency, at the price of fleet orchestration for migrations, connections and monitoring; Django supports it via DATABASES plus routers but nothing makes it cheap. The decision heuristic to state: row-level until a compliance or noisy-neighbour reason forces more; schema-per-tenant for tens-to-hundreds of mid-size tenants needing isolation stories; database-per-tenant for a handful of large regulated customers, often alongside a row-level pool for the long tail.
# shared-schema scoping, centralised
import contextvars
current_tenant = contextvars.ContextVar('tenant', default=None)
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tenant = resolve_tenant(request) # subdomain / membership
token = current_tenant.set(tenant)
try:
return self.get_response(request)
finally:
current_tenant.reset(token)
class TenantManager(models.Manager):
def get_queryset(self):
qs = super().get_queryset()
tenant = current_tenant.get()
return qs.filter(tenant=tenant) if tenant else qs.none()
class Job(models.Model):
tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
objects = TenantManager()
unscoped = models.Manager() # explicit, audited uses only
class Meta:
indexes = [models.Index(fields=['tenant', 'status'])]
Q56Cache stampedes and hot-key failures in cached Django systems: why get_or_set does not save you, and locking, jitter and soft-TTL strategies that do.
AdvancedCaching
Answer
A stampede (dogpile) happens when a hot cached value expires and every concurrent request misses simultaneously: hundreds of workers all run the expensive recomputation, the database or upstream API absorbs a spike it was being shielded from, latency explodes, and under enough load the recompute takes longer than the TTL, locking the system into permanent miss-storm, an outage created by the cache design itself. Django's cache.get_or_set() gives no protection: it is get-then-set across processes with no atomicity, so N workers that miss together all execute the callable. The mitigations layer.
TTL jitter: derive each key's timeout as base plus a random spread (300 plus up to 60 seconds) so keys populated together do not expire together, cheap insurance for families of keys warmed by the same event. Recompute locking: on miss, contenders race for a short-lived Redis lock (cache.add is atomic and works cross-process, or Lock from redis-py on django-redis); the winner recomputes and fills, losers either serve a stale copy or briefly wait-and-reread; caps recompute concurrency at one per key. Soft TTL (stale-while-revalidate): store the value with its logical expiry inside the payload while the physical Redis TTL runs much longer; requests finding a logically-stale value serve it immediately and trigger one asynchronous refresh (Celery task or the lock winner inline), so users never wait on recompute and the hard-miss path effectively disappears; this is the strategy behind most 'p99 dropped after caching rework' stories.
Negative caching handles a related storm, repeated misses for nonexistent keys (a scraper walking dead job ids): cache the 'not found' result briefly with a sentinel (never None, since cache.get returning None is indistinguishable from a miss). Finally, protect the backing store rather than assuming the cache is always there: a Redis restart is a 100% miss storm, so critical paths need database-side guardrails (statement_timeout, circuit breakers) and ideally warmup of the hottest keys before traffic cutover. Being able to sketch the soft-TTL wrapper from memory is a strong senior signal.
import random, time
from django.core.cache import cache
def jittered(base=300, spread=60):
return base + random.randint(0, spread)
STALE_GRACE = 600 # serve stale up to 10 min while refreshing
def get_with_soft_ttl(key, compute, ttl=300):
entry = cache.get(key)
now = time.time()
if entry and now < entry['fresh_until']:
return entry['value'] # fresh
if entry: # stale: one refresher
if cache.add(f'{key}:lock', 1, timeout=30):
refresh_cache_key.delay(key) # async recompute
return entry['value'] # serve stale NOW
# cold miss: lock so only one worker computes
if cache.add(f'{key}:lock', 1, timeout=30):
value = compute()
cache.set(key, {'value': value,
'fresh_until': now + ttl},
timeout=ttl + STALE_GRACE + jittered(0, 60))
cache.delete(f'{key}:lock')
return value
time.sleep(0.15) # loser: brief wait
entry = cache.get(key)
return entry['value'] if entry else compute() # last resort
Q57A production Django service is degrading: walk through the failure modes behind 'too many clients', idle-in-transaction pileups, WORKER TIMEOUT loops, DisallowedHost floods and memory creep.
AdvancedOperations
Answer
Interviewers pose this as a war-story prompt; the strong answer names each signature, its mechanism, and the fix. 'FATAL: sorry, too many clients already': connection budget exceeded, workers x threads x pods plus Celery plus crons versus max_connections; fix by computing the budget, adding PgBouncer transaction pooling or Django 5.1's native pool, and hunting leakers (long-running commands without close_old_connections). Idle-in-transaction pileup: pg_stat_activity shows transactions open for minutes doing nothing, usually ATOMIC_REQUESTS around slow views or an external HTTP call inside transaction.atomic; locks accumulate behind them and unrelated queries start timing out; fix by shrinking atomic blocks to pure-database work, moving network I/O outside or to on_commit, and setting idle_in_transaction_session_timeout as a backstop.
Gunicorn WORKER TIMEOUT loop: requests exceeding --timeout get their worker SIGKILLed, nginx returns 502, the retrying client re-triggers the same slow path, and the site flaps; the cause is usually one slow query or upstream call, found via APM or pg_stat_statements; align statement_timeout below gunicorn timeout below proxy_read_timeout so the query dies first with a clean error, then fix the query. DisallowedHost floods: Invalid HTTP_HOST header errors are internet scanners hitting your IP with random Host headers, harmless but they bury real errors and can page you; filter them out of error reporting (Sentry's ignore list or a logging filter on django.security.DisallowedHost) and drop unknown Hosts at nginx with a default server block. Memory creep: steady RSS growth across workers, classic causes being DEBUG=True recording connection.queries forever, unbounded per-process caches (lru_cache on methods keyed by instances, module-level dicts), large querysets loaded whole instead of iterator(), and genuine C-extension leaks; containment is --max-requests recycling while you diagnose with tracemalloc snapshots in a canary worker. The meta-answer that ties it together: none of these are guessable without observability, structured logs with request ids, APM tracing spans across view/ORM/external calls, pg_stat_statements, and dashboards for connection counts, worker restarts and p95 by endpoint, so the first investment in a mature Django deployment is instrumentation, not tuning folklore.
Key Points
- Connection math: workers x threads x pods + Celery + crons vs max_connections; PgBouncer or native pool
- idle-in-transaction = network I/O inside atomic / ATOMIC_REQUESTS on slow views; shrink transactions
- Timeout ladder: statement_timeout < gunicorn --timeout < proxy_read_timeout, so queries die cleanly
- DisallowedHost noise: scanner traffic; silence the logger, drop at nginx default server
- Memory creep: DEBUG queries log, unbounded caches, whole-table querysets; --max-requests contains, tracemalloc diagnoses
Q58Full-text search with django.contrib.postgres.search: query-time SearchVector versus a stored SearchVectorField with a GIN index, trigram fallbacks, and when you move to OpenSearch.
AdvancedSearch
Answer
Django ships a genuine PostgreSQL search integration, and the first decision is whether the tsvector is computed at query time or stored. The tutorial form, annotate(search=SearchVector('title', 'description')).filter(search=SearchQuery('django developer')), is correct but recomputes to_tsvector for every row on every request, so it collapses into a sequential scan once the table passes a few tens of thousands of rows. The production shape adds a SearchVectorField column, a GinIndex over it in Meta.indexes, and something that keeps it current: a refresh in the save path, a periodic management command doing batched updates, a database trigger, or a generated column (Django 5.0's GeneratedField, which PostgreSQL accepts only when the expression is immutable, so the text search configuration must be written explicitly rather than left to the session default).
Relevance comes from weights: build the vector as SearchVector('title', weight='A') plus SearchVector('description', weight='B'), then order by SearchRank with an explicit weights list. SearchQuery's search_type changes behaviour that users notice, 'plain' ANDs the terms, 'phrase' preserves order, and 'websearch' accepts quoted phrases and minus-prefixed exclusions the way a search box user expects. Full-text search does not tolerate typos, so most job and directory products pair it with pg_trgm: run the TrigramExtension operation in a migration, add a GinIndex with opclasses=['gin_trgm_ops'], and fall back to TrigramSimilarity ordering when the tsquery returns nothing.
Interviewers probe three details: unaccent for diacritics, config='simple' versus 'english' (English stemming over Indic or code-heavy text produces nonsense matches), and the fact that ILIKE '%term%' can never use a b-tree index no matter how many indexes you add. The honest limit is worth stating yourself: PostgreSQL search is excellent up to roughly single-digit millions of rows with modest relevance needs, and it wins operationally because there is no second datastore to keep in sync. Faceting, synonym dictionaries, per-user relevance tuning, typo tolerance at scale or semantic search are where you move to OpenSearch or a vector store and accept the indexing pipeline that comes with it.
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import (
SearchQuery, SearchRank, SearchVector, SearchVectorField,
TrigramSimilarity,
)
class Job(models.Model):
title = models.CharField(max_length=120)
description = models.TextField()
search = SearchVectorField(null=True, editable=False)
class Meta:
indexes = [
GinIndex(fields=['search'], name='job_search_gin'),
]
# refresh the stored vector in batches, not per request
Job.objects.filter(pk__in=ids).update(
search=SearchVector('title', weight='A', config='english')
+ SearchVector('description', weight='B', config='english'))
q = SearchQuery('backend django "pune" -intern', search_type='websearch')
results = (Job.objects.filter(search=q)
.annotate(rank=SearchRank('search', q,
weights=[0.1, 0.2, 0.4, 1.0]))
.order_by('-rank')[:20])
# typo tolerance fallback (needs pg_trgm + gin_trgm_ops index)
if not results:
results = (Job.objects
.annotate(sim=TrigramSimilarity('title', term))
.filter(sim__gt=0.3).order_by('-sim')[:20])
Q59How do you design indexes for real Django querysets: Meta.indexes with condition and include, functional indexes, composite column order, and reading queryset.explain(analyze=True)?
AdvancedIndexing
Answer
db_index=True on a field creates one single-column b-tree, which is rarely what a real query needs. Meta.indexes is the modern surface because it takes names, multiple columns with direction, expressions, partial conditions and included columns. Composite ordering follows the leftmost-prefix rule: put equality predicates first and the range or ordering column last, and match the sort direction, so models.Index(fields=['company', '-created_at']) lets filter(company=x).order_by('-created_at') run as a plain index scan with no Sort node above it, while the reverse column order forces a scan plus sort.
Partial indexes via condition=Q(status='active') keep the index small when every query carries that predicate, and UniqueConstraint with a condition is how you express 'only one active subscription per user' as a partial unique index instead of application-level checking. include=['title'] adds non-key payload columns so PostgreSQL can answer the query index-only. Functional indexes matter more than people expect: models.Index(Lower('email'), name='user_email_lower') is what makes filter(email__iexact=...) indexable, because lower(column) does not match a plain index on column. The same trap appears with dates, filter(created_at__date=today) casts the column and cannot use the timestamp index, so a half-open range with __gte and __lt is both faster and less ambiguous across time zones.
Diagnosis is queryset.explain(analyze=True, buffers=True), and you read it for four things: a Seq Scan over a large table, a Sort node the index should have removed, a high Rows Removed by Filter (the index is not selective enough), and a large gap between estimated and actual rows (stale statistics, run ANALYZE). The cost side is what separates senior answers: every index slows every INSERT and UPDATE, adds WAL volume and consumes disk, and an UPDATE touching an indexed column gives up the HOT optimisation. So audit rather than accumulate, pg_stat_user_indexes with idx_scan = 0 names indexes nobody has used since the last stats reset, and duplicates appear whenever db_index=True sits alongside a composite index starting with the same column. Create them in production with AddIndexConcurrently.
class Application(models.Model):
job = models.ForeignKey(Job, on_delete=models.CASCADE)
candidate = models.ForeignKey(settings.AUTH_USER_MODEL,
on_delete=models.CASCADE)
status = models.CharField(max_length=20)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
# equality first, ordering column last and in the same direction
models.Index(fields=['job', '-created_at'],
name='app_job_recent_idx'),
# partial: the shortlist screen always filters status
models.Index(fields=['job'], name='app_shortlisted_idx',
condition=models.Q(status='shortlisted')),
# covering: answer the list query without heap lookups
models.Index(fields=['candidate'], include=['status'],
name='app_candidate_cover_idx'),
]
constraints = [
models.UniqueConstraint(
fields=['job', 'candidate'], name='uniq_open_application',
condition=models.Q(status__in=['applied', 'shortlisted'])),
]
# read the plan, do not guess
print(Application.objects.filter(job_id=7)
.order_by('-created_at')[:20]
.explain(analyze=True, buffers=True))
-- find indexes nobody uses (and their cost)
SELECT relname, indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
Key Points
- Composite order: equality columns first, range/ORDER BY column last, direction must match
- condition= gives partial indexes; UniqueConstraint(condition=) gives partial uniqueness
- Lower('email') index is what makes __iexact indexable; __date casts and defeats the index
- explain(analyze=True, buffers=True): watch for Seq Scan, Sort nodes, Rows Removed by Filter
- Indexes cost writes and WAL; audit pg_stat_user_indexes for idx_scan = 0
Q60How do you move a large Django codebase from one LTS to the next: deprecation warnings, the removal calendar, third-party pins, and the settings that changed on the way?
AdvancedUpgrades
Answer
Django's calendar is predictable and the upgrade method follows from it: feature releases roughly every eight months, an LTS every two years (4.2 and 5.2 are the recent pair), and a deprecation policy where anything deprecated during a 5.x release is removed in 6.0, with the warning class literally naming the release (RemovedInDjango60Warning). So the approach is mechanical rather than heroic: upgrade one minor version at a time, 4.2 to 5.0 to 5.1 to 5.2, and at each step run the full test suite with python -W error::DeprecationWarning so every warning becomes a failing test, fix what it names, ship, repeat. Jumping straight from 4.2 to 5.2 means meeting three releases of removals simultaneously with no warnings left to guide you, because removed code just raises ImportError or TypeError.
Concrete items from that path are the ones interviewers check you have actually hit: DEFAULT_FILE_STORAGE and STATICFILES_STORAGE gave way to the STORAGES dict introduced in 4.2 and were removed in 5.1; Meta.index_together was replaced by Meta.indexes and removed in 5.1; USE_L10N disappeared in 5.0; django.utils.timezone.utc gave way to datetime.timezone.utc; CheckConstraint's check argument became condition. The larger half of the work usually is not Django at all, it is the dependency graph: DRF, django-filter, celery, django-storages, django-allauth, the psycopg2 to psycopg 3 move (a prerequisite for the 5.1 native connection pool), and anything that patches ORM internals. Read each package's supported-Django matrix before pinning, because unmaintained packages are the real reason teams sit on ancient versions for years.
The checklist worth reciting: read the backwards-incompatible section of every intervening release note rather than the highlights, run manage.py check and check --deploy, run makemigrations --check --dry-run in CI so a framework change does not silently generate a migration during deploy, rehearse migrations against a production-sized database copy, keep the staging PostgreSQL version identical, and roll out on a canary before the fleet. Each release also raises the minimum supported Python, so budget the runtime bump in the same window rather than discovering it on deploy day.
# 1) surface every deprecation as a test failure, one version at a time
python -W error::DeprecationWarning -W error::PendingDeprecationWarning \
-m pytest
# 2) settings churn you will actually hit on the 4.2 -> 5.2 path
# OLD (removed in 5.1)
# DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
# STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# NEW (Django 4.2+)
STORAGES = {
'default': {'BACKEND': 'storages.backends.s3.S3Storage'},
'staticfiles': {
'BACKEND':
'whitenoise.storage.CompressedManifestStaticFilesStorage'},
}
# 3) model Meta churn
class Meta:
# index_together = [['job', 'status']] # removed in 5.1
indexes = [models.Index(fields=['job', 'status'],
name='app_job_status_idx')]
constraints = [models.CheckConstraint(
condition=models.Q(notice_days__gte=0), # 'check=' is the old kwarg
name='notice_days_non_negative')]
# 4) CI gates that catch upgrade fallout early
python manage.py check --deploy --fail-level WARNING
python manage.py makemigrations --check --dry-run
Frequently Asked Questions
What does a Django developer earn in India in 2026?
The working band is ₹6-22 LPA, and the spread inside it is wide because Django roles sit in two very different markets. Service companies and agencies in Noida, Pune and Hyderabad hire freshers and 1-2 year developers at roughly ₹3.5-7 LPA for CRUD, admin and DRF work. Product companies (HackerEarth, Instamojo, HealthifyMe, fintech and health-tech startups) pay ₹10-18 LPA at the 3-5 year mark for engineers who can own ORM performance, Celery pipelines and PostgreSQL behaviour. Senior and staff engineers who genuinely handle migrations on large tables, connection budgeting, caching strategy and multi-tenant design clear ₹22-35 LPA, and remote roles for US or EU teams go higher. The salary jump almost never comes from knowing more Django features; it comes from being able to explain query plans, transaction boundaries and deploy safety.
How long should I prepare for a Django interview?
If you already write Django daily, two to three weeks of focused revision is enough: one week on the ORM performance cluster (select_related versus prefetch_related, annotate double-counting, F() and select_for_update, assertNumQueries), one week on DRF, Celery, caching and testing, and a few days on deployment and migration safety. If you know Python but have only built tutorial projects, plan eight to twelve weeks and spend most of it building one non-trivial application with real data volume, because interviewers can tell within two questions whether you have ever watched a query plan or debugged an N+1 in production. The single highest-return exercise is loading a table with a few hundred thousand rows locally, then measuring your own endpoints with django-debug-toolbar and fixing what you find.
What is expected from a fresher versus someone with four years of Django?
A fresher is judged on fundamentals and honesty: the MVT request cycle, models and migrations, QuerySet laziness, forms or DRF serializers, the difference between null and blank, and one project you can explain end to end including why you made specific choices. Nobody expects you to know PgBouncer. At three to five years the bar moves to production behaviour: why a page fires 400 queries and how you proved it, when transaction.atomic is too wide, how Celery tasks stay idempotent when they retry, what breaks during a rolling deploy that adds a NOT NULL column, and how you would cache a hot endpoint and invalidate it correctly. Interviewers at that level are essentially asking whether you have been on call for a Django service, so bring one specific incident you diagnosed and fixed.
Is Django still worth learning in 2026 when FastAPI exists?
Yes, and the two are not really competing for the same job. FastAPI is a fast, async-first API layer, and you assemble the rest (ORM choice, migrations, admin, auth, permissions) from separate libraries. Django hands you a tested version of all of that on day one, which is exactly why it keeps winning products with real business rules: dashboards, marketplaces, fintech back-offices, internal tooling, anything where an admin interface and a permissions model would otherwise be months of work. It has also modernised, with an async ORM interface, async views and first-party PostgreSQL connection pooling, so the old 'Django cannot do async' objection is out of date. In the Indian job market Django postings still outnumber FastAPI postings by a wide margin, and most FastAPI roles ask for Django experience anyway.
Django versus Flask versus Node for a backend career in India?
Flask gives you the most freedom and the least structure, which suits small services and ML model wrappers; it teaches you less about the parts of backend work that interviews actually probe, because you choose an ORM, a migration tool and an auth scheme yourself and often choose them shallowly. Django teaches the whole stack of concerns in one place and dominates hiring for data-heavy web products. Node with NestJS or Express is the other big Indian market and pays comparably, with the advantage that one language covers frontend and backend. The pragmatic play: pick Django if you like Python and want depth in databases and product logic, learn DRF and PostgreSQL properly alongside it, and treat Celery plus Redis as part of the package. Language wars matter far less to your salary than being able to explain a slow query.
What should I learn alongside Django to be interview-ready?
Four things, in this order. PostgreSQL beyond basic SQL: indexes, EXPLAIN, transactions and locks, because most hard Django questions are database questions in disguise. Django REST Framework, since the majority of new Django work is API work behind a React or mobile client. Celery with Redis for background jobs, retries and scheduling, which appears in nearly every mid-level interview. And deployment literacy: Docker, gunicorn workers, environment-based settings, and what a rolling deploy does to your migrations. Beyond that, pytest-django and assertNumQueries make your performance claims provable, and basic observability (structured logs, a tracing tool, pg_stat_statements) is what lets you answer 'how did you find that bottleneck' with a method instead of a guess.
Introduction
Twenty years after its first release, Django remains the framework most Python backends in production are actually built on. Instagram still runs one of the largest Django deployments on the planet, and in India companies from HackerEarth and Instamojo to the Django teams inside TCS and Infosys client projects ship on it daily. The framework has quietly modernised: Django 5.2 LTS brought composite primary keys, 5.1 added native PostgreSQL connection pooling, and the ORM grew a full async interface. The batteries-included pitch (ORM, migrations, admin, auth, forms, security middleware) is still what wins it enterprise work over Flask and FastAPI.
Django interviews in India have a very recognisable shape. Screening rounds check whether you understand the MVT request cycle, migrations, and QuerySet laziness. Mid-rounds almost always land on the ORM performance cluster: select_related versus prefetch_related, N+1 detection, annotate pitfalls, and transaction.atomic. Product companies add Django REST Framework, Celery, Redis caching, and testing with pytest-django. Senior rounds go straight at production: zero-downtime migrations on large PostgreSQL tables, connection pool exhaustion, race conditions under concurrency, and how you would scale reads with replicas. Interviewers at fintech and health-tech firms probe security settings line by line.
This guide contains 60 Django interview questions ordered basic to intermediate to advanced, written against Django 5.x behaviour and the way hiring actually works in 2026. Every answer explains how the feature behaves in production, the gotcha interviewers are fishing for, and where candidates typically lose the round. More than half the questions carry runnable code. Work through the basic section to lock down fundamentals, then spend most of your preparation time on the ORM performance, DRF, Celery, and migration-safety questions: those are the ones that separate offers at ₹8 LPA from offers at ₹20 LPA.
Ready to practice Django interviews?
Don't just read, practice these Django questions live with an AI interviewer that asks follow-ups and scores your answers.