Skip to content
Backend & systems

37 Django and FastAPI Interview Questions and Answers

This focused guide turns RecallDeck’s curated Django and FastAPI material into 37 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

37 min read37 detailed answersReviewed Aug 24, 2026
What to remember

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

01

What are WSGI and ASGI, and what's the difference?

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.

02

Why do we need ASGI if WSGI satisfied everyone for years?

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:

  1. 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).
  2. No WebSocket / SSE. The "request-response" model finishes immediately; there's no room for a long-lived connection.
  3. No native async. You can't await inside 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.

03

What's the difference between gunicorn and uvicorn? How are they combined?

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.

04

What is MTV in Django and how does it map to MVC?

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.

05

Describe the path of a request in Django from the server to the response.

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:

  1. The server (gunicorn) receives the HTTP request, builds environ, and calls django.core.wsgi.get_wsgi_application().
  2. Django creates an HttpRequest object.
  3. The request passes down through MIDDLEWARE (the "request" phase of each middleware).
  4. The URLResolver matches the path against urlpatterns and finds the view.
  5. The view executes its logic: ORM queries, template rendering, assembling data.
  6. The view returns an HttpResponse.
  7. The response passes up through the middleware in reverse order (the "response" phase).
  8. 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.

06

How does URL routing work in Django?

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.

07

Function-Based Views vs Class-Based Views — which to choose?

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.

08

How is middleware structured in Django, and why does order matter?

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.

09

Explain the concept of middleware and why order matters.

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 MIDDLEWARE list.
  • 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_request hooks.

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.

10

How are settings and apps organized in Django?

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.

11

Explain migrations and QuerySet laziness in the Django ORM.

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).

12

How do you tackle N+1 queries in Django? (see the ORM file)

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.

13

How do forms and validation work in Django?

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.

14

What is the Django admin and how do you customize it?

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.

15

What are signals in Django and why do you need to be careful with them?

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:

  1. Implicitness. Logic runs "magically" on save — it's invisible from the code that called save(). Hard to debug.
  2. Ordering and cascades. Multiple receivers on one signal, or a signal emitted inside a receiver → cascades, infinite loops.
  3. bulk_create/update() don't send signals. Model.objects.update(...) and bulk_create do NOT trigger post_save — a frequent source of bugs.
  4. Transactions. A post_save receiver runs inside the transaction; if it sends an email or enqueues a Celery task and the transaction rolls back, you get desync. Use transaction.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.

16

What security mechanisms does Django give you out of the box?

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 &lt; 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.

17

What's the difference between Serializer and ModelSerializer in DRF?

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.

18

What are a ViewSet and a router in DRF?

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.

19

How do authentication and permission classes work in DRF?

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.

20

How do you set up pagination and rate limiting in DRF?

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, but OFFSET is 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.

21

What is FastAPI built on and why is it async-first?

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).

22

How does FastAPI distinguish path, query, and body parameters? What is response_model?

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.

23

How does Dependency Injection via Depends work in FastAPI?

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.

24

Explain the idea of Dependency Injection and why it's needed.

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 managementyield opens/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.

25

How does FastAPI generate documentation?

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.

26

What is BackgroundTasks and when should you use it?

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).

27

What makes FastAPI considered fast?

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:

  1. 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 def removes that advantage.
  2. 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.
  3. Pydantic v2 — the validation core pydantic-core is written in Rust, many times faster than v1 (pure Python).
  4. 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.

28

What is Pydantic and how does v2 differ from v1?

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 Configmodel_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.

29

What is Flask, and what are the app/request context and blueprints?

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 contextcurrent_app, g. Active for the duration of request handling (or manually via with app.app_context()).
  • Request contextrequest, 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().

30

How do you handle exceptions in these frameworks?

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.

31

How do you set up CORS in Django/DRF, FastAPI, and Flask?

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).

32

How is authentication implemented in DRF and FastAPI?

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).

33

How do you test Django and FastAPI applications?

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.

34

How are static files, media, and templates handled?

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.

35

How are Python web applications deployed, and how many workers should you run?

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.

36

How are background tasks integrated using Celery?

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)).

37

How do you choose between Django, FastAPI, and Flask?

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.

Start studying

Keep going

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS