Organize the answer around ownership, limits, failure, and recovery. Definitions become interview-ready when they survive a concrete production scenario.
Question set
37 detailed answers
01What are WSGI and ASGI, and what's the difference?
middle
Short answer: WSGI (Web Server Gateway Interface) is a synchronous standard interface between a web server and a Python application. ASGI (Asynchronous Server Gateway Interface) is its asynchronous successor, supporting async/await, WebSocket, and long-polling. WSGI = one connection blocks a worker; ASGI = one worker serves many connections concurrently.
In depth:
WSGI is defined in PEP 3333. The application is a callable (environ, start_response) -> iterable:
# WSGI application (synchronous)
def application(environ, start_response):
status = "200 OK"
headers = [("Content-Type", "text/plain")]
start_response(status, headers)
return [b"Hello WSGI"]
The processing model is strictly synchronous: a worker takes a request, executes it entirely (including blocking I/O — DB, HTTP to external services), and only then takes the next one. Django (classically) and Flask run on WSGI.
ASGI is async def app(scope, receive, send). scope describes the connection (type: http, websocket, lifespan), and receive/send are asynchronous event channels:
# ASGI application (asynchronous)
async def application(scope, receive, send):
assert scope["type"] == "http"
await send({
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
})
await send({
"type": "http.response.body",
"body": b"Hello ASGI",
})
FastAPI, Starlette, modern Django (with async def views), and Channels run on ASGI.
| WSGI | ASGI | |
|---|---|---|
| Model | synchronous | asynchronous (async/await) |
| Signature | (environ, start_response) |
async (scope, receive, send) |
| WebSocket | no | yes |
| Concurrency | via processes/threads | via event loop + threads/processes |
| Servers | gunicorn, uWSGI | uvicorn, hypercorn, daphne |
| Frameworks | Django, Flask | FastAPI, Starlette, async Django |
⚠️ Gotcha: ASGI doesn't make "ordinary" (CPU-bound) code faster. The gain is only on I/O-bound tasks, where a lot of time is spent waiting. If you call a blocking function inside an async def view (e.g., a synchronous DB driver or time.sleep), you'll block the entire event loop and kill concurrency for all connections on that worker.
02Why do we need ASGI if WSGI satisfied everyone for years?
concept
Short answer: WSGI fundamentally doesn't support long-lived connections (WebSocket, SSE) or asynchronous I/O. ASGI solves both problems: it lets a single worker hold thousands of open connections and efficiently serve I/O-bound load without bloating the number of processes/threads.
In depth:
WSGI's problems:
- One request = one busy worker. With 100 concurrent "slow" requests (e.g., waiting 2 sec for a response from an external API) you need ~100 workers/threads. Each process eats memory (tens to hundreds of MB).
- No WebSocket / SSE. The "request-response" model finishes immediately; there's no room for a long-lived connection.
- No native async. You can't
awaitinside a view.
With ASGI, a single event loop concurrently serves a multitude of coroutines waiting on I/O:
# FastAPI: while waiting on the external API, the event loop serves other requests
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/proxy")
async def proxy():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data") # await => loop is free
return r.json()
When to choose what:
- CPU-bound, simple CRUD, few concurrent connections → WSGI is quite enough.
- Lots of slow I/O, real-time (chats, notifications), streaming, high concurrency → ASGI.
⚠️ Gotcha: ASGI is not a "silver bullet." Under it, it's easy to get degradation if you accidentally call synchronous blocking code. In Django under ASGI, the ORM remains synchronous — DB calls are wrapped in sync_to_async (via a thread pool), and careless use negates the gain.
03What's the difference between gunicorn and uvicorn? How are they combined?
middle
Short answer: gunicorn is a production WSGI server with a worker-process manager (pre-fork). uvicorn is a lightweight ASGI server built on uvloop/httptools. In practice, you often run gunicorn as the process manager with the worker class uvicorn.workers.UvicornWorker, to get both robust process management and ASGI.
In depth:
# Pure WSGI (Django/Flask)
gunicorn myproject.wsgi:application --workers 5 --bind 0.0.0.0:8000
# Pure ASGI (FastAPI) — uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# Best of both worlds: gunicorn manages, uvicorn handles ASGI
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
Why gunicorn in front of uvicorn: gunicorn provides mature process management — graceful reload, restarting stuck workers (--timeout), monitoring. The uvicorn workers provide async handling.
gunicorn worker types (WSGI):
sync(default) — 1 request per worker, robust, for CPU-bound.gthread— threads within a worker (--threads N), for mixed load.gevent/eventlet— green threads, for I/O-bound on WSGI.
⚠️ Gotcha: Don't run uvicorn in --reload mode in production — it's for development and is slower. Also don't confuse --workers (processes) with --threads (threads within a process): async workers usually don't need threads, since the event loop provides the concurrency.
04What is MTV in Django and how does it map to MVC?
junior
Short answer: Django uses the MTV pattern — Model (data/ORM), Template (presentation/HTML), View (business logic, decides what to show). It's the same as MVC, but the "Controller" in Django is the framework itself (the URL dispatcher), and the "View" in MVC corresponds to the "Template" in Django.
In depth:
| MVC | Django MTV | Role |
|---|---|---|
| Model | Model | Data, ORM, business entities |
| View | Template | Presentation (HTML/JSON) |
| Controller | View | Request-handling logic |
| — | (URLconf) | Routing (the MVC "controller" role) |
# Model — models.py
class Article(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
# View — views.py (this is the "controller" in MVC terms)
from django.shortcuts import render
def article_detail(request, pk):
article = Article.objects.get(pk=pk)
return render(request, "article.html", {"article": article})
# Template — article.html
# <h1>{{ article.title }}</h1><p>{{ article.body }}</p>
⚠️ Gotcha: A common point of confusion in interviews: in Django, the "View" is NOT what the user sees (as in MVC), but the request handler. What the user sees is the Template.
05Describe the path of a request in Django from the server to the response.
middle
Short answer: WSGI/ASGI server → Django's WSGI/ASGI handler → the middleware stack (down) → URL resolver → view → (ORM/template) → HttpResponse → the middleware stack (up) → server → client.
In depth:
- The server (gunicorn) receives the HTTP request, builds
environ, and callsdjango.core.wsgi.get_wsgi_application(). - Django creates an
HttpRequestobject. - The request passes down through
MIDDLEWARE(the "request" phase of each middleware). - The
URLResolvermatches the path againsturlpatternsand finds the view. - The view executes its logic: ORM queries, template rendering, assembling data.
- The view returns an
HttpResponse. - The response passes up through the middleware in reverse order (the "response" phase).
- The server returns the response to the client.
def my_view(request):
# request is an HttpRequest
name = request.GET.get("name", "World")
return HttpResponse(f"Hello, {name}") # this is an HttpResponse
⚠️ Gotcha: If a middleware returns a response early (e.g., a redirect to login in the auth middleware), the lower middleware and the view won't run at all — this is a short circuit. It's important to understand at which stage each middleware interrupts the chain.
06How does URL routing work in Django?
junior
Short answer: A urlpatterns list maps URL patterns to views. path() (with type converters) and re_path() (regex) are used. URLconf can be nested via include() for app modularity.
In depth:
# project/urls.py
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")), # delegate to the app
]
# blog/urls.py
from django.urls import path, re_path
from . import views
urlpatterns = [
path("", views.index, name="blog-index"),
path("<int:pk>/", views.detail, name="blog-detail"), # int converter
path("<slug:slug>/", views.by_slug), # slug converter
re_path(r"^archive/(?P<year>[0-9]{4})/$", views.archive),
]
name= lets you reverse a URL without hardcoding:
from django.urls import reverse
url = reverse("blog-detail", kwargs={"pk": 42}) # "/blog/42/"
# In a template: {% url 'blog-detail' pk=42 %}
⚠️ Gotcha: The order of patterns matters — Django takes the first match. A <slug:slug> placed before <int:pk>/ can intercept requests. Also don't forget trailing slashes: the APPEND_SLASH setting does a redirect, but only for GET, and will break a POST without a trailing slash.
07Function-Based Views vs Class-Based Views — which to choose?
middle
Short answer: FBVs (function-based) are simple (request) -> response functions, explicit and readable. CBVs (class-based) are classes with get/post methods, providing reuse via inheritance and mixins; generic views cut down boilerplate for CRUD.
In depth:
# FBV
from django.shortcuts import render, get_object_or_404
def article_detail(request, pk):
article = get_object_or_404(Article, pk=pk)
return render(request, "detail.html", {"article": article})
# CBV — generic
from django.views.generic import DetailView, ListView
class ArticleDetail(DetailView):
model = Article
template_name = "detail.html"
class ArticleList(ListView):
model = Article
paginate_by = 20
# urls.py: path("<int:pk>/", ArticleDetail.as_view())
- FBV pros: simplicity, explicitness, easy to read; decorators (
@login_required) are obvious. - CBV pros: DRY for typical CRUD, mixins (
LoginRequiredMixin,PermissionRequiredMixin), overriding methods (get_queryset,get_context_data). - CBV cons: the "magic" of inheritance, hard to trace where a method comes from (MRO).
⚠️ Gotcha: In CBVs, decorators aren't applied directly but via method_decorator or mixins. @login_required on a def get(...) method won't work as expected — you need @method_decorator(login_required, name="dispatch") on the class or LoginRequiredMixin.
08How is middleware structured in Django, and why does order matter?
middle
Short answer: Middleware are wrapper layers around the view that process every request/response. They're invoked downward through the MIDDLEWARE list on the way in and upward (in reverse order) on the way out. The modern style is a callable class that wraps get_response.
In depth:
class TimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response # called once at startup
def __call__(self, request):
# --- code BEFORE the view (request phase, top-to-bottom order) ---
import time
start = time.monotonic()
response = self.get_response(request) # call the next layer / the view
# --- code AFTER the view (response phase, bottom-to-top order) ---
response["X-Elapsed-Ms"] = int((time.monotonic() - start) * 1000)
return response
Additional hooks: process_view, process_exception, process_template_response.
A typical order (it matters!):
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", # before auth
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", # needs session
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
⚠️ Gotcha: AuthenticationMiddleware depends on SessionMiddleware — if you place it above, request.user will be unavailable. The order is precisely the dependency contract between the layers.
09Explain the concept of middleware and why order matters.
concept
Short answer: Middleware is a chain of wrappers (the "onion"/decorator-chain pattern) around the business logic, implementing cross-cutting functionality (logging, auth, CORS, compression, security). Order matters because the request goes through the layers in one direction and the response in the reverse, and layers depend on data set by previous ones.
In depth:
The onion model: the request "penetrates" inward through the layers, the view is at the center, and the response "exits" through the same layers in reverse order.
request → [Security] → [Session] → [Auth] → [CSRF] → VIEW
response ← [Security] ← [Session] ← [Auth] ← [CSRF] ← VIEW
This is a concept common to all frameworks:
- Django: the
MIDDLEWARElist. - FastAPI/Starlette:
app.add_middleware(...)— those added later wrap those added earlier (the outermost layer = the last one added). - Flask: WSGI middleware +
before_request/after_requesthooks.
Why order is critical:
- The auth middleware must come after the session middleware (it reads the session).
- The GZip middleware must compress the already-ready response — usually closer to the outer layer.
- CORS must run early, so that preflight requests don't reach the heavy logic.
⚠️ Gotcha: In FastAPI/Starlette the order is inverted relative to intuition: the middleware added last via add_middleware runs FIRST for a request (it's the outermost). It's easy to confuse with Django, where the first in the list is the outermost.
10How are settings and apps organized in Django?
junior
Short answer: settings.py is a single configuration module (DB, INSTALLED_APPS, middleware, secrets). An app is a self-contained module of functionality (models.py, views.py, migrations/) registered in INSTALLED_APPS. A project is made up of many apps.
In depth:
# settings.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"rest_framework", # third-party
"blog.apps.BlogConfig", # your own app
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "mydb",
"HOST": os.environ["DB_HOST"],
}
}
SECRET_KEY = os.environ["SECRET_KEY"] # from env, not hardcoded!
DEBUG = os.environ.get("DEBUG", "0") == "1"
For different environments, settings are split up: base.py, dev.py, prod.py, and you point DJANGO_SETTINGS_MODULE at the right one.
# blog/apps.py
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "blog"
def ready(self):
import blog.signals # register signals
⚠️ Gotcha: DEBUG = True in production is a serious vulnerability: it exposes stack traces with environment variables, and ALLOWED_HOSTS is ignored. Likewise, a SECRET_KEY in the repository means compromised sessions and tokens.
11Explain migrations and QuerySet laziness in the Django ORM.
middle
Short answer: Migrations are versioned Python files describing changes to the DB schema (makemigrations generates them, migrate applies them). A QuerySet is lazy — the SQL is not executed when it's created, only on the first access to the data (iteration, len, slice, list(), bool).
In depth:
# Migrations
# python manage.py makemigrations -> creates 0002_xxx.py
# python manage.py migrate -> applies to the DB
# python manage.py showmigrations -> status
# Laziness
qs = Article.objects.filter(published=True) # SQL NOT executed
qs = qs.exclude(draft=True) # still NOT executed (chaining)
qs = qs.order_by("-created") # NOT executed
for a in qs: # <-- RIGHT HERE the SQL is executed (evaluation)
print(a.title)
print(len(qs)) # already cached, no new query
What triggers execution: iteration, list(qs), len(qs), bool(qs), slicing with a step, repr().
select_related loads ForeignKey/OneToOne relations with a SQL JOIN, while prefetch_related issues a second query for many-to-many and reverse relations and joins the results in Python. Indexes should match actual QuerySet filters and ordering, and transaction.atomic() provides an explicit transaction boundary for dependent writes.
⚠️ Gotcha: qs.count() runs SELECT COUNT(*), while len(qs) loads ALL objects into memory and counts them. To check "are there any records", use qs.exists() (an efficient SELECT 1 ... LIMIT 1), not if qs: or len(qs).
12How do you tackle N+1 queries in Django? (see the ORM file)
middle
Short answer: select_related is for ForeignKey/OneToOne and does a SQL JOIN (a single query). prefetch_related is for ManyToMany/reverse FKs and does a separate query, joining the results in Python. Both eliminate the N+1 problem.
In depth:
# N+1 PROBLEM: 1 query for the articles + N queries for the authors
for article in Article.objects.all():
print(article.author.name) # a separate SQL query on every iteration
# select_related — FK/O2O, via JOIN
for article in Article.objects.select_related("author"):
print(article.author.name) # all in one query
# prefetch_related — M2M / reverse relations
for article in Article.objects.prefetch_related("tags"):
print([t.name for t in article.tags.all()]) # 2 queries total
A detailed breakdown (when to use which, Prefetch objects, only/defer, annotations) is in the Django ORM file in this same directory.
⚠️ Gotcha: select_related over a M2M is impossible (you can't JOIN a set of rows into one) — for M2M, only prefetch_related works. Excessive select_related with deep chains produces huge JOINs and duplicated data.
13How do forms and validation work in Django?
middle
Short answer: forms.Form/forms.ModelForm declaratively describe fields, validate input (is_valid()), clean it (cleaned_data), and render HTML. ModelForm builds fields automatically from a model. Validation happens at the field level (clean_<field>) and the form level (clean).
In depth:
from django import forms
class ContactForm(forms.Form):
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea, max_length=2000)
def clean_message(self): # validating a single field
data = self.cleaned_data["message"]
if "spam" in data.lower():
raise forms.ValidationError("No spam allowed")
return data
def clean(self): # cross-field validation
cleaned = super().clean()
# ... comparing several fields
return cleaned
# ModelForm — fields from the model
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["title", "body"]
# in the view
def view(request):
form = ContactForm(request.POST or None)
if request.method == "POST" and form.is_valid():
send_email(form.cleaned_data) # data is already cleaned and typed
⚠️ Gotcha: You can't access form.cleaned_data before calling form.is_valid() — the attribute doesn't exist yet. And in clean_<field> you must return the value, otherwise the field becomes None in cleaned_data.
14What is the Django admin and how do you customize it?
junior
Short answer: The Django admin is an auto-generated CRUD panel for your models, built from INSTALLED_APPS. Models are registered via admin.site.register or the @admin.register decorator, and behavior is configured through ModelAdmin (lists, filters, search, inlines).
In depth:
from django.contrib import admin
class CommentInline(admin.TabularInline):
model = Comment
extra = 1
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "author", "published", "created")
list_filter = ("published", "created")
search_fields = ("title", "body")
raw_id_fields = ("author",) # instead of a heavy dropdown
inlines = [CommentInline]
readonly_fields = ("created",)
The admin is great for internal tools and the work of content managers.
⚠️ Gotcha: The admin is full data access with write/delete permissions. Don't hand out admin access carelessly, and don't expose /admin/ without restrictions (rate-limiting, a non-standard path, 2FA). Also, a list_display with a method that hits a related model easily creates N+1 — use list_select_related.
15What are signals in Django and why do you need to be careful with them?
senior
Short answer: Signals are a publish/subscribe mechanism: a sender emits a signal (post_save, pre_delete, m2m_changed) and subscriber receivers react. Convenient for loose coupling, but dangerous: hidden logic, ordering problems, and difficulty debugging and testing.
In depth:
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
Why be careful:
- Implicitness. Logic runs "magically" on save — it's invisible from the code that called
save(). Hard to debug. - Ordering and cascades. Multiple receivers on one signal, or a signal emitted inside a receiver → cascades, infinite loops.
bulk_create/update()don't send signals.Model.objects.update(...)andbulk_createdo NOT triggerpost_save— a frequent source of bugs.- Transactions. A
post_savereceiver runs inside the transaction; if it sends an email or enqueues a Celery task and the transaction rolls back, you get desync. Usetransaction.on_commit.
Alternative: call a method/service explicitly instead of using a signal, when the logic is unambiguous.
⚠️ Gotcha: Profile.objects.create() inside a post_save for User triggers post_save again (for Profile) — watch out for infinite recursion. And remember: queryset.update() bypasses both save() and signals.
16What security mechanisms does Django give you out of the box?
middle
Short answer: By default Django protects against CSRF (tokens in forms), XSS (auto-escaping in templates), SQL injection (parameterized ORM queries), clickjacking (X-Frame-Options), and it supports secure password storage (PBKDF2/Argon2) and HTTPS settings.
In depth:
# CSRF: a token is required for POST forms
# in the template: <form method="post">{% csrf_token %} ... </form>
# CsrfViewMiddleware checks the token; otherwise 403
# XSS: auto-escaping in Django Templates
# {{ user_input }} -> < is turned into < automatically
# Disabling it is dangerous: {{ user_input|safe }} or mark_safe()
# SQL injection: the ORM parameterizes queries
Article.objects.filter(title=user_input) # safe (placeholder)
Article.objects.raw("SELECT * FROM a WHERE t=%s", [user_input]) # safe
# DANGEROUS: .raw(f"... WHERE t='{user_input}'") — injection!
# Clickjacking
MIDDLEWARE += ["django.middleware.clickjacking.XFrameOptionsMiddleware"]
X_FRAME_OPTIONS = "DENY"
# Production settings
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
⚠️ Gotcha: The |safe filter and mark_safe() turn off XSS protection — never apply them to user input. And raw()/extra()/.raw() with f-strings undo SQL-injection protection — always pass parameters as a list, not via interpolation.
17What's the difference between Serializer and ModelSerializer in DRF?
middle
Short answer: Serializer is a manual, declarative description of fields and (de)serialization/validation, giving you full control. ModelSerializer generates fields automatically from a model and provides ready-made create/update, saving code for typical CRUD.
In depth:
from rest_framework import serializers
# Manual Serializer
class ArticleSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
title = serializers.CharField(max_length=200)
body = serializers.CharField()
def create(self, validated_data):
return Article.objects.create(**validated_data)
def validate_title(self, value): # field validation
if Article.objects.filter(title=value).exists():
raise serializers.ValidationError("Title must be unique")
return value
# ModelSerializer — fields and create/update automatically
class ArticleModelSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source="author.name", read_only=True)
class Meta:
model = Article
fields = ["id", "title", "body", "author_name"]
read_only_fields = ["id"]
A serializer works both ways: serializer.data (object → dict/JSON) and serializer.is_valid() + serializer.validated_data (input dict → validated data).
⚠️ Gotcha: fields = "__all__" in a ModelSerializer is an anti-pattern for an API: when you add a field to the model, it will automatically leak into the API (e.g., is_admin, password_hash). Specify fields explicitly. Also, nested serializers are read-only by default — for writing you need a custom create/update.
18What are a ViewSet and a router in DRF?
middle
Short answer: A ViewSet groups the logic for a set of related endpoints (list/create/retrieve/update/destroy) into one class. A Router automatically generates the URL routes for that ViewSet, sparing you from writing urlpatterns by hand.
In depth:
from rest_framework import viewsets
from rest_framework.routers import DefaultRouter
from rest_framework.decorators import action
from rest_framework.response import Response
class ArticleViewSet(viewsets.ModelViewSet): # CRUD out of the box
queryset = Article.objects.select_related("author")
serializer_class = ArticleSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
@action(detail=True, methods=["post"]) # extra endpoint /articles/{pk}/publish/
def publish(self, request, pk=None):
article = self.get_object()
article.published = True
article.save()
return Response({"status": "published"})
# urls.py
router = DefaultRouter()
router.register(r"articles", ArticleViewSet) # generates all routes
urlpatterns = router.urls
The hierarchy: APIView (base) → GenericAPIView + mixins → generic views → ViewSet/ModelViewSet.
⚠️ Gotcha: ModelViewSet gives you all operations at once, including DELETE and PUT/PATCH. If you only need read access, use ReadOnlyModelViewSet or restrict http_method_names, otherwise you'll accidentally expose write/delete.
19How do authentication and permission classes work in DRF?
middle
Short answer: Authentication classes determine WHO is making the request (they populate request.user). Permission classes determine WHETHER they're allowed to perform the action. Authentication runs first, then the permission check. Both are configured globally or at the view level.
In depth:
# settings.py — globally
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
# at the view level
from rest_framework.permissions import IsAuthenticated, BasePermission
class IsOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.author == request.user
class ArticleViewSet(viewsets.ModelViewSet):
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated, IsOwner]
Built-in permissions: AllowAny, IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly, DjangoModelPermissions.
Two levels of permission checks: has_permission (for the whole request) and has_object_permission (for a specific object, called from get_object).
⚠️ Gotcha: has_object_permission is NOT called automatically for list endpoints or for objects you don't retrieve through get_object() — for lists you need to filter the queryset manually (get_queryset). Otherwise the user will see other people's records in the list, even though they have no permission on the individual object.
20How do you set up pagination and rate limiting in DRF?
middle
Short answer: Pagination splits large lists into pages (PageNumberPagination, LimitOffsetPagination, CursorPagination). Throttling limits request frequency (by user/anonymous/scope) to protect against abuse.
In depth:
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
"user": "1000/day",
"uploads": "10/min", # scoped throttle
},
}
# scoped throttle on a specific view
class UploadView(APIView):
throttle_scope = "uploads"
Pagination types:
PageNumberPagination—?page=2, simple, butOFFSETis slow on large datasets.LimitOffsetPagination—?limit=20&offset=40.CursorPagination— stable under inserts, efficient on large tables (over an indexed field).
⚠️ Gotcha: PageNumberPagination/LimitOffsetPagination use SQL OFFSET, which is slow on large tables (the DB scans and discards the skipped rows) and unstable under inserts/deletes (pages shift). For large volumes and infinite scroll, use CursorPagination.
21What is FastAPI built on and why is it async-first?
middle
Short answer: FastAPI = Starlette (an ASGI framework: routing, middleware, WebSocket) + Pydantic (validation and serialization via type hints). It's async-first because Starlette is built on ASGI; type hints give you validation, documentation, and autocomplete "for free".
In depth:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel): # Pydantic — validation from type hints
name: str
price: float
in_stock: bool = True
@app.post("/items/")
async def create_item(item: Item) -> Item: # async + typed body
return item
What the stack provides:
- Starlette — ASGI routing, middleware, background tasks, WebSocket, a test client.
- Pydantic — parsing/validating input, serializing output, generating JSON Schema → OpenAPI.
Both def and async def views are supported: FastAPI runs synchronous functions in a separate thread pool so they don't block the event loop.
⚠️ Gotcha: If you declare async def but call a blocking operation inside (synchronous SQLAlchemy, requests, time.sleep), you'll block the event loop. Either use async libraries (httpx, an async DB driver), or declare the view as a regular def (then FastAPI itself moves it to the thread pool).
22How does FastAPI distinguish path, query, and body parameters? What is response_model?
middle
Short answer: FastAPI infers a parameter's source from its type and signature: a variable from the path → a path param; a simple type not in the path → a query param; a Pydantic model → the request body. response_model defines the response schema: it validates, filters, and documents the output.
In depth:
from fastapi import FastAPI, Query, Path
from pydantic import BaseModel
app = FastAPI()
class ItemIn(BaseModel):
name: str
password: str
class ItemOut(BaseModel): # no password — it gets filtered out
name: str
@app.get("/items/{item_id}")
async def read_item(
item_id: int = Path(..., gt=0), # path param, > 0
q: str | None = Query(None, max_length=50), # query param ?q=
limit: int = 10, # query param with a default
):
return {"item_id": item_id, "q": q, "limit": limit}
@app.post("/items/", response_model=ItemOut) # response constrained to the ItemOut schema
async def create(item: ItemIn):
save(item)
return item # password will NOT appear in the response — filtered by response_model
response_model is a powerful security tool: even if you return extra fields, only those described in the model make it into the response.
⚠️ Gotcha: Without a response_model (or -> ReturnType), FastAPI returns everything the function returned, including sensitive fields (like is_admin, a password hash). Always define an explicit output model for endpoints that return data.
23How does Dependency Injection via Depends work in FastAPI?
middle
Short answer: Depends declares a dependency — a function/class that FastAPI calls, injecting the result into the parameter. Dependencies can be nested, are cached within a request, and support yield for setup/teardown (e.g., a DB session).
In depth:
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
app = FastAPI()
# dependency with teardown via yield
def get_db():
db = SessionLocal()
try:
yield db # the value is injected into the parameter
finally:
db.close() # runs after the response
# dependency that uses another dependency
def get_current_user(db: Session = Depends(get_db), token: str = Header(...)):
user = db.query(User).filter_by(token=token).first()
if not user:
raise HTTPException(status_code=401)
return user
@app.get("/me")
async def me(user: User = Depends(get_current_user)):
return user
# dependency at the level of the whole router / application
app = FastAPI(dependencies=[Depends(verify_api_key)])
Benefits: reuse, testability (via app.dependency_overrides), declarativeness, automatic representation in OpenAPI.
⚠️ Gotcha: A dependency is cached within a SINGLE request (if the same Depends appears several times, the function is called once). To disable caching, use Depends(func, use_cache=False). Also, the part after yield runs only after the response is sent — don't count on changing the response there.
24Explain the idea of Dependency Injection and why it's needed.
concept
Short answer: DI is passing a component its dependencies from the outside rather than creating them inside. This reduces coupling and makes it easier to swap implementations and test (you can substitute a dependency with a mock). FastAPI makes DI declarative via Depends.
In depth:
# WITHOUT DI: the dependency is hardwired inside — hard to test
def get_user():
db = ProductionDatabase() # tight coupling
return db.fetch_user()
# WITH DI: the dependency is passed in from outside
def get_user(db: Database = Depends(get_db)):
return db.fetch_user()
# In a test we swap the real DB for a fake
app.dependency_overrides[get_db] = lambda: FakeDatabase()
Why:
- Testability — substituting real services with mocks.
- Loose coupling — code depends on an abstraction, not a concrete implementation.
- Reuse — shared logic (auth, DB session, pagination) is extracted into a dependency.
- Lifecycle management —
yieldopens/closes resources.
Equivalents in other frameworks: Django does this less explicitly (settings, middleware, request.user); Flask — via flask.g and current_app.
⚠️ Gotcha: DI ≠ a global singleton. A common mistake is creating a heavy object (e.g., an HTTP client) inside a dependency on every request instead of reusing it. For expensive resources, use lifespan events or a module-level singleton, and hand back the already-created object through Depends.
25How does FastAPI generate documentation?
junior
Short answer: From type hints and Pydantic models, FastAPI automatically builds an OpenAPI schema (JSON Schema). It exposes interactive UIs based on it: Swagger UI (/docs) and ReDoc (/redoc), plus the raw /openapi.json.
In depth:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="My API", version="1.0.0")
class Item(BaseModel):
name: str = Field(..., description="Product name", examples=["Book"])
price: float = Field(..., gt=0, description="Price > 0")
@app.post("/items/", summary="Create a product", tags=["items"])
async def create(item: Item):
"""Creates a new product. The docstring text becomes the endpoint description."""
return item
Available automatically:
/docs— Swagger UI (interactive, you can send requests)./redoc— ReDoc (clean documentation)./openapi.json— the spec for generating clients, importing into Postman, etc.
⚠️ Gotcha: In production the interactive docs are often disabled or locked behind authentication (docs_url=None, redoc_url=None) so as not to reveal the API structure. Also, Swagger shows request examples — don't leave real secrets/tokens in examples.
26What is BackgroundTasks and when should you use it?
middle
Short answer: BackgroundTasks lets you run a function AFTER the response has been sent to the client, without blocking it (sending an email, logging, cache invalidation). It's a lightweight alternative to Celery for short tasks that aren't reliability-critical.
In depth:
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def write_log(message: str):
with open("log.txt", "a") as f:
f.write(message + "\n")
@app.post("/send/")
async def send(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(write_log, f"sent to {email}")
return {"status": "queued"} # the response goes out immediately, the log is written afterward
When to use BackgroundTasks vs. Celery:
- BackgroundTasks — short, in-process, non-critical tasks. They run in the same process; if the process crashes, the task is lost.
- Celery — long, heavy tasks requiring reliability/retries/distribution across workers.
⚠️ Gotcha: BackgroundTasks run in the same process/worker — a heavy task (CPU-bound, a minute-long export) will block the worker and eat its resources. For reliability (guaranteed execution, retries) and heavy tasks, use only a full-fledged queue (Celery/RQ/ARQ).
27What makes FastAPI considered fast?
concept
Short answer: Thanks to ASGI + async I/O (one worker holds many concurrent requests), a fast ASGI server (uvicorn on uvloop/httptools, partly in C), and Pydantic v2 validation (core in Rust). On I/O-bound workloads, FastAPI is close to Node.js/Go performance.
In depth:
The ingredients of speed:
- ASGI + asyncio — one event loop serves many I/O-bound requests while they wait for a network or database operation; blocking synchronous work inside
async defremoves that advantage. - uvicorn / uvloop — uvloop is a replacement for the asyncio event loop based on libuv (the same one used in Node.js); httptools is a fast HTTP parser in C.
- Pydantic v2 — the validation core
pydantic-coreis written in Rust, many times faster than v1 (pure Python). - Minimal overhead — Starlette is thin, with no heavy ORM/admin in the core.
# Concurrency: while we wait on 3 external requests, the loop overlaps them
import asyncio, httpx
@app.get("/aggregate")
async def aggregate():
async with httpx.AsyncClient() as c:
a, b, d = await asyncio.gather( # in parallel, not sequentially
c.get("https://s1/"), c.get("https://s2/"), c.get("https://s3/"),
)
return {"s1": a.json(), "s2": b.json(), "s3": d.json()}
⚠️ Gotcha: "FastAPI is fast" applies to I/O-bound scenarios. On pure CPU-bound code, Python is still Python (GIL), and async doesn't help — for CPU load you need processes (multiple workers) or offloading to Celery/a process pool. "FastAPI vs Flask" comparisons on a bare "hello world" are misleading.
28What is Pydantic and how does v2 differ from v1?
middle
Short answer: Pydantic is a library for validating/serializing data from Python type hints via BaseModel. v2 was rewritten with a Rust core (pydantic-core) — many times faster, with a new validator API (field_validator, model_validator) and a separate pydantic-settings package for configuration.
In depth:
from pydantic import BaseModel, field_validator, model_validator, EmailStr, Field
class User(BaseModel):
name: str = Field(..., min_length=1)
email: EmailStr
age: int = Field(..., ge=0, le=120)
@field_validator("name") # v2 (in v1 it was @validator)
@classmethod
def name_titlecase(cls, v: str) -> str:
return v.title()
@model_validator(mode="after") # cross-field validation
def check(self):
if self.age < 18 and self.name == "admin":
raise ValueError("admin must be adult")
return self
u = User(name="ann", email="a@b.com", age="30") # "30" is coerced to int 30
# u.name == "Ann"
Key differences v1 → v2:
- Rust core → performance.
@validator→@field_validator,@root_validator→@model_validator..dict()→.model_dump(),.json()→.model_dump_json(),.parse_obj()→.model_validate().class Config→model_config = ConfigDict(...).- Settings moved into a separate package
pydantic-settings.
# Application configuration from env
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_url: str
debug: bool = False
model_config = {"env_file": ".env"}
settings = Settings() # reads from environment variables / .env
⚠️ Gotcha: In v2, BaseSettings moved from pydantic to pydantic_settings — a common migration mistake (ImportError). And remember that v2 does "soft" type coercion by default ("30" → 30); if you need strictness, use Field(strict=True) or StrictInt.
29What is Flask, and what are the app/request context and blueprints?
middle
Short answer: Flask is a microframework (WSGI) built on Werkzeug (HTTP/routing) and Jinja2 (templates). It gives you a minimum out of the box, with the rest provided through extensions. Blueprints modularize the application. The application context and request context are mechanisms for accessing global objects (current_app, request, g) within a request.
In depth:
from flask import Flask, Blueprint, request, g, current_app, render_template
app = Flask(__name__)
# Blueprint — an application module
bp = Blueprint("blog", __name__, url_prefix="/blog")
@bp.route("/<int:post_id>")
def post(post_id):
return render_template("post.html", post_id=post_id) # Jinja2
app.register_blueprint(bp)
@app.before_request
def load_user():
g.user = get_user_from_session() # g — per-request storage
Flask contexts:
- Application context —
current_app,g. Active for the duration of request handling (or manually viawith app.app_context()). - Request context —
request,session. Access to the current request's data.
These are "magic" proxy objects (via werkzeug.local) bound to the current context (previously a thread, now contextvars).
Extensions: Flask-SQLAlchemy (ORM), Flask-Migrate, Flask-Login, Flask-RESTful, Marshmallow (serialization).
⚠️ Gotcha: Accessing request/current_app outside of a request context raises RuntimeError: Working outside of application/request context — a common problem in background tasks and scripts. The fix: wrap it in with app.app_context(): or app.test_request_context().
30How do you handle exceptions in these frameworks?
middle
Short answer: Each framework provides a way to catch an exception and return a structured response: FastAPI — @app.exception_handler + HTTPException; DRF — a custom EXCEPTION_HANDLER/APIException; Flask — @app.errorhandler; Django — process_exception middleware and custom handler404/500.
In depth:
# FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/items/{id}")
async def get(id: int):
if id == 0:
raise HTTPException(status_code=404, detail="Not found")
class TooManyError(Exception): ...
@app.exception_handler(TooManyError) # global handler for the type
async def handler(request: Request, exc: TooManyError):
return JSONResponse(status_code=429, content={"error": "slow down"})
# Flask
@app.errorhandler(404)
def not_found(e):
return {"error": "not found"}, 404
# DRF — settings.py: "EXCEPTION_HANDLER": "app.views.custom_exception_handler"
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
resp = exception_handler(exc, context) # the standard one first
if resp is not None:
resp.data = {"error": resp.data}
return resp
⚠️ Gotcha: Don't return raw tracebacks to the client in production (leaking internal structure) — this is especially dangerous with Django DEBUG=True. Log the details on the server, and give the client a generic message and a request_id.
31How do you set up CORS in Django/DRF, FastAPI, and Flask?
middle
Short answer: CORS (Cross-Origin Resource Sharing) is a mechanism that lets the browser access an API from a different origin. It's configured via middleware/an extension with a list of allowed origins, methods, and headers. Django — django-cors-headers, FastAPI — CORSMiddleware, Flask — flask-cors.
In depth:
# FastAPI (Starlette)
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
allow_credentials=True,
)
# Django — pip install django-cors-headers
MIDDLEWARE = ["corsheaders.middleware.CorsMiddleware", ...] # as high as possible
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CORS_ALLOW_CREDENTIALS = True
# Flask — pip install flask-cors
from flask_cors import CORS
CORS(app, origins=["https://app.example.com"])
CORS is a check on the browser side, not the server. The server simply returns Access-Control-Allow-* headers, and the browser does the enforcement.
⚠️ Gotcha: allow_origins=["*"] TOGETHER with allow_credentials=True is forbidden by the spec — the browser will reject it. You must list specific origins. And allow_origins=["*"] in production is a hole: any website can hit your API on behalf of the user (especially dangerous with cookie auth).
32How is authentication implemented in DRF and FastAPI?
middle
Short answer: DRF uses authentication classes (Session, Token, JWT via third-party packages) that populate request.user. FastAPI uses security dependencies (OAuth2PasswordBearer, HTTPBearer, APIKeyHeader) via Depends, which validate the token and return the user.
In depth:
# DRF — JWT via djangorestframework-simplejwt
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
}
# request.user is populated automatically from the token
# FastAPI — OAuth2 + JWT
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status_code=401)
return get_user(payload["sub"])
@app.get("/me")
async def me(user = Depends(get_current_user)):
return user
The difference in philosophy: DRF — class configuration (declaratively in settings), FastAPI — dependencies (explicit in the signature, visible in OpenAPI).
⚠️ Gotcha: Don't confuse authentication (who you are) with authorization (what you're allowed to do). Also, with JWT remember: tokens can't be "revoked" without additional infrastructure (a blacklist/short TTL + refresh) — they're not sessions. Keep SECRET out of the code, and always specify algorithms=[...] explicitly (otherwise the alg=none vulnerability).
33How do you test Django and FastAPI applications?
middle
Short answer: Django — django.test.TestCase (wraps each test in a transaction that's rolled back) + Client for requests, or pytest-django with fixtures. FastAPI — TestClient (based on httpx/Starlette) or async httpx.AsyncClient. They all use a test database.
In depth:
# Django + pytest-django
import pytest
@pytest.mark.django_db
def test_article_list(client):
Article.objects.create(title="X")
resp = client.get("/articles/")
assert resp.status_code == 200
# Django TestCase
from django.test import TestCase
class ArticleTests(TestCase):
def setUp(self):
Article.objects.create(title="X") # rolled back after the test
def test_list(self):
resp = self.client.get("/articles/")
self.assertEqual(resp.status_code, 200)
# FastAPI
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create():
resp = client.post("/items/", json={"name": "Book", "price": 10})
assert resp.status_code == 200
assert resp.json()["name"] == "Book"
# overriding dependencies in tests
app.dependency_overrides[get_db] = override_get_db
⚠️ Gotcha: TestCase wraps the test in a transaction and rolls it back — so code that checks commit behavior or transaction.on_commit hooks won't fire; for that, use TransactionTestCase. In FastAPI, TestClient is synchronous (it runs the event loop internally) — to test real concurrency you need httpx.AsyncClient with ASGITransport.
34How are static files, media, and templates handled?
junior
Short answer: Static — the app's CSS/JS/images (STATIC_URL, collectstatic). Media — files uploaded by users (MEDIA_URL/MEDIA_ROOT). Templates: Django Templates (its own language) or Jinja2 (Flask/FastAPI). In production, static files are served by nginx/a CDN, not by Python.
In depth:
# Django settings
STATIC_URL = "/static/"
STATIC_ROOT = "/var/www/static/" # collectstatic gathers everything here
MEDIA_URL = "/media/"
MEDIA_ROOT = "/var/www/media/" # user uploads
# python manage.py collectstatic -> gathers static files from all apps
Template engines:
{# Django Template #}
{% for a in articles %}
<li>{{ a.title|upper }}</li> {# filters via | #}
{% endfor %}
{% url 'detail' pk=a.pk %}
{# Jinja2 (Flask / FastAPI) — more like Python #}
{% for a in articles %}
<li>{{ a.title | upper }}</li>
{% endfor %}
{{ url_for('detail', pk=a.pk) }}
Django Templates are intentionally limited (no arbitrary Python in the template); Jinja2 is more powerful (expressions, calls) and faster.
⚠️ Gotcha: Don't serve static/media through Django in production (DEBUG=True serves them with the dev server, but that's slow and insecure). Use nginx/a CDN, or WhiteNoise for static files. And media (user files) can't be mixed with static — they can't be trusted and need type/size validation.
35How are Python web applications deployed, and how many workers should you run?
middle
Short answer: The standard stack is: nginx (reverse proxy, TLS, static files) → gunicorn/uvicorn (app server with N workers) → application. For WSGI/CPU-bound work, the number of workers ≈ 2 * CPU + 1. For async (uvicorn) it's usually equal to the number of cores — concurrency comes from the event loop.
In depth:
# nginx — the front: TLS, static files, proxying
server {
listen 443 ssl;
location /static/ { alias /var/www/static/; } # nginx serves static files
location / {
proxy_pass http://127.0.0.1:8000; # to gunicorn/uvicorn
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
# WSGI (Django/Flask): 4 cores -> 2*4+1 = 9 workers
gunicorn myproject.wsgi:application --workers 9 --bind 127.0.0.1:8000
# ASGI (FastAPI): usually ~= the number of cores
gunicorn main:app -k uvicorn.workers.UvicornWorker --workers 4
The 2 * CPU + 1 formula: while one worker waits on I/O, another can run on the same core. It's a heuristic for sync workers. Async workers don't need many processes (a single worker is already concurrent) — you take ~the number of cores to utilize all CPUs.
Why put nginx in front of the app server: TLS termination, buffering of slow clients (protection against slowloris), serving static files, load balancing, compression, rate limiting.
⚠️ Gotcha: Too many workers = memory oversubscription (each worker holds its own copy of the app, DB pool, cache) and degradation. Account for RAM, not just CPU. And 2*CPU+1 is about processes; don't confuse it with threads, and don't apply it blindly to async workers.
36How are background tasks integrated using Celery?
middle
Short answer: Celery is a distributed task queue: the application puts a task into a broker (Redis/RabbitMQ), separate workers pick it up and execute it asynchronously. It's used for long/heavy operations (sending emails, generating reports, image processing) so they don't block the web request.
In depth:
# celery_app.py
from celery import Celery
celery = Celery("myapp", broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1")
@celery.task(bind=True, max_retries=3)
def send_report(self, user_id):
try:
generate_and_email(user_id)
except SMTPError as exc:
raise self.retry(exc=exc, countdown=60) # retry after 60 sec
# in a view (Django/FastAPI)
@app.post("/reports/")
def create_report(user_id: int):
send_report.delay(user_id) # enqueues and returns immediately
return {"status": "queued"}
# starting the worker and periodic tasks
celery -A myapp worker --loglevel=info --concurrency=4
celery -A myapp beat # scheduler (cron-like tasks)
Components: broker (task transport), workers (executors), backend (result storage), beat (scheduling).
⚠️ Gotcha: Pass an ID into the task, not the objects themselves (models/sessions) — the object gets serialized and goes stale; the right way is to load a fresh one from the DB inside the task. And in Django a common mistake is enqueuing a task to Celery before the transaction commits: the worker starts and can't find the record. The fix is transaction.on_commit(lambda: task.delay(id)).
37How do you choose between Django, FastAPI, and Flask?
concept
Short answer: Django is "batteries included" for full-featured applications with a database, admin, auth, and forms (monoliths, CMS, classic backends). FastAPI is a modern async API-first service (microservices, ML inference, high I/O concurrency). Flask is a microframework for small/flexible applications and prototypes.
In depth:
| Criterion | Django | FastAPI | Flask |
|---|---|---|---|
| Type | full-stack "batteries" | API-first, async | microframework |
| ORM | own, built-in | none (SQLAlchemy/Tortoise) | none (extensions) |
| Admin | yes, powerful | none | none |
| Async | partial (since 3.x) | native | limited |
| Validation | forms/serializers (DRF) | Pydantic (built-in) | manual/extensions |
| Auto-docs | via DRF (add-ons) | OpenAPI out of the box | extensions |
| Learning curve | steep | medium | gentle |
| Best for | monoliths, CMS, CRUD | microservices, ML APIs | prototypes, small services |
Choosing by scenario:
- Django (+DRF): a project with an admin, a complex domain model, auth/permissions out of the box, a team that values conventions. A classic backend and a REST API on top of a database.
- FastAPI: a new service focused on APIs, high I/O concurrency (external APIs, WebSockets), where you need auto-docs and strict validation, ML inference endpoints.
- Flask: a small service, a prototype, a non-standard architecture, minimal dependencies, full control over the stack.
⚠️ Gotcha: "FastAPI is faster than Django, so we'll take FastAPI" is faulty logic. Django gives you a huge ecosystem (admin, ORM, auth, forms) that would take a lot of time to recreate in FastAPI. The framework's speed is rarely the bottleneck — that's far more often the database. Choose based on the task and the ecosystem, not on "hello world" benchmarks.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.