TL;DR: Senior Python backend interviews in 2026 test concurrency choices, ORM query behaviour, API design and production debugging more than syntax. Candidates should know that Python 3.14 made the free-threaded build officially supported. Django 6.1 added fetch modes that cut most N+1 loops to two queries.
Python 3.14 changed the default way multiprocessing starts workers on Linux, from fork to forkserver. Code that relied on inherited globals broke on upgrade. That is the kind of detail a senior backend interview now probes. The GIL, the event loop and the ORM still decide most of the talk. The answers below match the current releases.
- 1Current versions as of September 2026: Python 3.14.7, Django 6.1.1, FastAPI 0.141, Pydantic 2.13, SQLAlchemy 2.0.54 and pytest 9.1.
- 2Python 3.14.0 to 3.14.4 shipped an incremental garbage collector; 3.14.5 reverted it after reports of memory pressure in production.
- 3Django 6.0 added a built-in background Tasks framework, but it ships no worker: execution still needs external infrastructure.
- 4Django 5.2 is the current LTS, with support until April 2028. From 2028, Django moves to year-based versions and every feature release gets three years of support.
Core Python Internals
1. What does the GIL prevent, and when do threads still help?
The Global Interpreter Lock lets only one thread run Python bytecode at a time in the standard CPython build. Threads therefore do not speed up CPU-bound Python code.
They still help with I/O. A thread that waits on a socket or a file releases the GIL. Other threads run in the meantime. Many C extensions, such as NumPy and hashlib on large inputs, also release it during heavy work. The exception to all of this is the free-threaded build, covered in question 26.
2. How does CPython free memory?
Mostly through reference counting: every object counts the references to it and is freed the moment the count reaches zero. That makes cleanup predictable: in CPython, a file object is closed as soon as its last reference goes away.
Reference counting cannot free cycles, such as two objects that point to each other. A cyclic garbage collector runs now and then to find them. One detail worth knowing: Python 3.14.0 to 3.14.4 shipped an incremental collector with shorter pauses. 3.14.5 reverted it to the 3.13 collector after reports of memory pressure in production.
3. What is a descriptor, and where do you use one without noticing?
A descriptor is an object that defines __get__, __set__ or __delete__ and controls attribute access when it is stored on a class. property, classmethod, staticmethod and ordinary methods are all descriptors: a function's __get__ is what binds self.
ORMs use them too. A Django foreign key attribute is a descriptor that runs a query the first time you read it. That is how N+1 problems start. The Descriptor HowTo Guide is the reference.
4. When would you use a metaclass instead of __init_subclass__?
Rarely. __init_subclass__ runs whenever a class is subclassed and covers the common metaclass use cases: registering plugins, validating class attributes and setting defaults.
class Handler:
registry: dict[str, type["Handler"]] = {}
def __init_subclass__(cls, *, event: str, **kwargs):
super().__init_subclass__(**kwargs)
Handler.registry[event] = cls
class PaymentFailed(Handler, event="payment.failed"):
...
A metaclass is still needed to change how the class object itself is built or behaves. Examples are a custom __prepare__ or a class that is itself iterable. Django's ModelBase is a metaclass for that reason. Two metaclasses in one hierarchy also conflict, which is another reason to avoid them.
5. What do __slots__ buy you, and what do they cost?
__slots__ replaces the per-instance __dict__ with fixed storage for the named attributes. That saves memory when a process holds millions of small objects, and it makes typos in attribute names raise AttributeError.
The cost: no attributes beyond the listed ones, no __dict__ unless you list it, and care with multiple inheritance. Dataclasses can generate them with @dataclass(slots=True).
6. Protocol or abstract base class for a service interface?
A typing.Protocol describes a shape that any class matches without inheriting from it; an ABC requires inheritance (or explicit registration) and can hold shared code. For dependency injection in a backend, a Protocol is usually the better fit.
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, customer_id: str, cents: int) -> str: ...
def checkout(gateway: PaymentGateway, customer_id: str, cents: int) -> str:
return gateway.charge(customer_id, cents)
A test fake or a third-party client matches the Protocol without importing your code. Type checkers verify the match; at runtime nothing is checked unless you add @runtime_checkable.
7. How do you stream a large export without loading it into memory?
Use a generator that yields rows or chunks, and a response type that consumes an iterator. Memory then stays flat whatever the export size.
import csv, io
from django.http import StreamingHttpResponse
def rows(queryset):
buf = io.StringIO()
writer = csv.writer(buf)
for order in queryset.iterator(chunk_size=2000):
writer.writerow([order.id, order.total])
yield buf.getvalue()
buf.seek(0)
buf.truncate(0)
def export(request):
return StreamingHttpResponse(rows(Order.objects.all()), content_type="text/csv")
QuerySet.iterator() matters as much as the generator: without it Django caches every fetched row on the queryset. FastAPI and Starlette offer the same pattern with StreamingResponse.
Async and Concurrency
8. Threads, processes or asyncio: how do you choose for a backend workload?
Match the tool to the bottleneck. For I/O-bound work with thousands of concurrent waits, use asyncio. Threads suit I/O-bound work with tens of waits or blocking libraries. For CPU-bound work, use separate processes, or threads on the free-threaded build.

Many real services mix them. A typical mix is an async web tier, a thread pool for one blocking SDK, and a task queue for heavy jobs. A strong answer names the bottleneck before the tool.
9. What happens when a coroutine calls a blocking function?
The whole event loop stops. asyncio runs every task on one thread and switches only at await. A time.sleep(2), a sync database call or a heavy loop freezes every other request for that long.
The fixes are an async library (httpx.AsyncClient, asyncpg) or moving the call off the loop with await asyncio.to_thread(func, *args). Running with PYTHONASYNCIODEBUG=1 logs callbacks that take longer than 100 ms, which finds these calls in development.
10. Why use asyncio.TaskGroup instead of asyncio.gather?
TaskGroup, added in Python 3.11, gives structured concurrency. If one task fails, the group cancels the rest and raises all errors together as an ExceptionGroup. With gather, a failure in one task leaves the others running unless you cancel them yourself.
async def load_dashboard(user_id: int):
async with asyncio.TaskGroup() as tg:
profile = tg.create_task(get_profile(user_id))
orders = tg.create_task(get_orders(user_id))
return profile.result(), orders.result()
Handle the grouped errors with except*. See the asyncio task documentation.
11. How do you put a timeout on an async call, and what does cancellation do?
Wrap the call in async with asyncio.timeout(seconds): (Python 3.11 and later). When time runs out, the task is cancelled. A CancelledError is raised at its current await, and the context manager turns it into TimeoutError.
Cleanup code in finally still runs. Code that catches CancelledError and does not re-raise it breaks timeouts and TaskGroup cancellation, which is a common bug in retry loops.
12. What is the difference between WSGI and ASGI?
WSGI is a synchronous interface: the server calls the application once per request and waits for the response. ASGI is its async successor and also supports long-lived connections such as WebSockets and server-sent events.
Django supports both; FastAPI and Starlette are ASGI only. In production, an ASGI app typically runs under Uvicorn or Hypercorn, and a WSGI app under Gunicorn. The number of worker processes, not the framework, sets how many CPU cores the service uses.
Web Frameworks and APIs
13. Walk through a Django request.
The WSGI or ASGI server hands the request to Django, which runs it through the middleware stack in order. The URL resolver then picks a view, which returns a response. The response passes back through the middleware in reverse order.
Middleware is where cross-cutting concerns live: sessions, authentication, CSRF and security headers. A middleware can return a response early, for example to reject an unauthenticated request, and then the view never runs.
14. select_related or prefetch_related?
select_related follows foreign keys and one-to-one relations with a SQL join, in the same query. prefetch_related runs one extra query per relation and joins the results in Python, which is what many-to-many and reverse foreign keys need.
orders = (Order.objects
.select_related("customer") # JOIN
.prefetch_related("items__product")) # 2 extra queries
A strong candidate also mentions how they find N+1 queries: Django Debug Toolbar, assertNumQueries in tests, or query logging. Django 6.1 adds a third option, covered in question 30.
15. How does dependency injection work in FastAPI?
A path function declares what it needs with Depends(), and FastAPI calls those dependencies per request and passes in the results. A dependency that uses yield runs its cleanup after the response, which suits database sessions.
async def get_session():
async with SessionLocal() as session:
yield session
@app.get("/orders/{order_id}")
async def read_order(order_id: int, session: AsyncSession = Depends(get_session)):
return await session.get(Order, order_id)
In tests, app.dependency_overrides[get_session] = fake_session swaps the real session out without patching anything.
16. In FastAPI, when should a path function be def and when async def?
Use async def only when everything inside it awaits async libraries. Use plain def when it calls blocking code. The FastAPI docs say a plain def path function "is run in an external threadpool that is then awaited".
The worst case is async def with a blocking call inside, such as the sync requests library. It runs on the event loop and stalls every other request.
17. What changed for validation code moving from Pydantic 1 to Pydantic 2?
Pydantic 2 moved validation into a compiled core, pydantic-core, and renamed most of the API. @validator became @field_validator, .dict() became .model_dump(), parse_obj became model_validate, and class Config became model_config = ConfigDict(...).
Behaviour changed too: strictness around type coercion differs, so an upgrade needs tests that feed real payloads, not just a search-and-replace.
18. Sessions or JWTs for API authentication?
Server-side sessions are simpler to revoke: delete the session and the user is logged out.
- A store lookup on every request
- Instant revocation: delete the row
- Suits first-party web apps
- Signed and self-contained: no lookup
- Valid until expiry unless you keep a deny list
- Suits short-lived tokens between services
A JWT needs no lookup, but it stays valid until it expires. Revoking it early needs a deny list, which brings the lookup back.
A common design for first-party web apps is a session cookie (HttpOnly, Secure, SameSite). For services calling each other, short-lived JWTs work well. Whatever the choice, passwords are hashed with a slow algorithm: Django uses PBKDF2 with SHA256 by default and supports Argon2 and bcrypt.
Data and Architecture
19. How do you manage database connections in a Python service?
Through a pool, so requests reuse open connections instead of paying for a new one each time. SQLAlchemy pools by default; pool_pre_ping=True tests a connection before use so a restarted database does not cause errors.
Django keeps persistent connections with CONN_MAX_AGE, and with psycopg 3 it also supports a real pool via "OPTIONS": {"pool": True}, per the database docs. Pool size times worker count must stay under the database's connection limit; PgBouncer helps when it does not.
20. How do you run a schema migration without downtime?
Split every breaking change into backward-compatible steps, so old and new code can run side by side during a deploy. This is often called expand and contract.

Two other traps. A new index on a large Postgres table should use CREATE INDEX CONCURRENTLY (Django's AddIndexConcurrently). And a migration that rewrites a big table can lock it for minutes.
21. How do you make a Celery task safe to run twice?
Assume it will run twice and make it idempotent. With acks_late=True, a task is acknowledged only after it finishes, so a worker crash means the broker delivers it again.
Idempotency comes from the task's design. Check or upsert on a unique key, such as an order ID. Or add a unique constraint so the second run fails harmlessly. Pass IDs to tasks, not model objects, so the task reads current data.
22. How do you prevent a cache stampede?
A stampede happens when a popular key expires and hundreds of requests rebuild it at once, hitting the database together. Three common fixes:
- A lock so only one request rebuilds the value while others wait or serve the stale copy.
- Random jitter on expiry times so keys do not all expire at the same second.
- Refreshing hot keys in the background before they expire.
Also decide what happens when the cache is down. The service should fall back to the database with a rate limit, not fail every request.
23. Two requests update the same row at once. How do you avoid lost updates?
Either let the database do the arithmetic, or lock the row. An F() expression turns the update into one SQL statement, so no value is read into Python first.
from django.db import transaction
from django.db.models import F
Product.objects.filter(pk=pid).update(stock=F("stock") - 1)
with transaction.atomic():
account = Account.objects.select_for_update().get(pk=aid)
account.balance -= amount
account.save()
select_for_update() holds a row lock until the transaction ends, so keep that block short. Optimistic locking with a version column is the alternative when conflicts are rare.
Testing and Tooling
24. Where do you patch when mocking?
Patch the name where it is looked up, not where it is defined. If orders/service.py does from payments import charge, the test must patch orders.service.charge.
def test_checkout_charges_card(mocker):
charge = mocker.patch("orders.service.charge", return_value="ch_1")
assert checkout("cust_1", 500) == "ch_1"
charge.assert_called_once_with("cust_1", 500)
The unittest.mock docs call this "where to patch". Heavy mocking is also a design signal. Passing the gateway in, as in question 6, often removes the need to patch.
25. How do you find why an endpoint is slow in production?
Measure before guessing. Start with request traces or APM data to see whether time goes to the database, external calls or Python itself.
For Python-level time, py-spy samples a running process without restarting it. Locally, cProfile gives exact call counts. For the database, log slow queries and run EXPLAIN ANALYZE on them. Python 3.14 adds python -m asyncio ps PID for stuck async code, covered in question 28.
What Changed Recently
forkserver default on Linux.on_delete.26. What does free-threaded Python mean for a backend service?
It is a separate CPython build (python3.14t) without the GIL, so threads run Python code on several cores at once. Python 3.13 introduced it as experimental, and PEP 779 made it officially supported in 3.14. It is still not the default build.
The 3.14 release notes put the single-threaded slowdown at roughly 5 to 10%. Before adopting it, check that every C extension in the stack supports it, since an incompatible one can re-enable the GIL. Most web services are I/O-bound and gain little; CPU-heavy workers gain the most.
27. Why did some multiprocessing code break on Python 3.14 under Linux?
Because the default start method changed. On Unix platforms other than macOS, forkserver replaced fork as the default for multiprocessing and ProcessPoolExecutor, per the 3.14 release notes.
With fork, a child inherited the parent's memory, including globals set at runtime. With forkserver, the task function and its arguments must be picklable, and state must be passed in explicitly. Code that needs the old behaviour can request it with mp_context=multiprocessing.get_context("fork"), though fork is unsafe in processes that run threads.
28. How do you see what a stuck asyncio service is doing?
On Python 3.14, run python -m asyncio ps PID or python -m asyncio pstree PID against the live process. The 3.14 release notes describe ps as a table of every task, its coroutine stack and which tasks await it.
Before 3.14, the options were asyncio.all_tasks() from inside the process, or py-spy dump, which shows threads but not task relationships. Naming tasks with create_task(..., name="...") makes the output far easier to read.
29. Does Django 6.0's Tasks framework replace Celery?
Not by itself. Django 6.0 added a standard API for defining and enqueuing background tasks, with a @task decorator and .enqueue(), configured through the TASKS setting.
from django.core.mail import send_mail
from django.tasks import task
@task
def email_users(emails, subject, message):
return send_mail(subject, message, None, emails)
email_users.enqueue(emails=["user@example.com"], subject="Hi", message="Hello")
The release notes are explicit that Django "does not provide a worker mechanism", and the two built-in backends are meant for development and testing. Production still needs a backend package and workers. What changes is that application code targets one Django API instead of a specific queue library.
30. What are fetch modes in Django 6.1?
They control what happens when code reads a field or relation that was not loaded. Django 6.1 offers three:
FETCH_ONE, the default and the old behaviour: fetch it for this instance only.FETCH_PEERS: fetch it for every instance from the same queryset, like an on-demandprefetch_related().FETCH_RAISE: raiseFieldFetchBlockedinstead of querying.
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
print(book.author.name) # 2 queries in total, not 1 + N
FETCH_PEERS turns most N+1 loops into two queries without listing relations up front. FETCH_RAISE is useful in tests and hot paths, where an accidental query should fail loudly.
Signs of a Strong Answer
- They name the bottleneck (I/O, CPU, database, lock) before naming a concurrency tool.
- They spot blocking calls inside
async defand knowasyncio.to_thread. - They count queries:
select_related,prefetch_related, fetch modes andassertNumQueries. - They design tasks and migrations to survive running twice or running beside old code.
- They patch where a name is looked up, and prefer injecting dependencies to patching.
- They know which Python and Django versions their production stack runs, and what 3.14 changed.
Hiring Python Developers
Second Talent places pre-vetted Python developers from Asia, including Django and FastAPI backend engineers, screened with questions like these. See typical rates in our Python developer cost guide.
Tell us the stack and we send a shortlist within 24 hours. Start hiring, or see our Django and PostgreSQL interview guides.






