Field Notes — Python · Django · Django REST Framework
Twelve questions that
separate junior from senior.
A walkthrough of the Python internals and Django/DRF architecture decisions that quietly determine whether your application leaks memory, leaks tenants, or just works — written as an interactive reference, not a wall of text.
Start reading ↓Tuple vs. list — and when to reach for which
A list and a tuple are both ordered, indexable sequences — but the difference that actually matters in practice is mutability and intent. A tuple is a sealed record; a list is a container you expect to keep changing.
tuple
point = (3, 7)
- Immutable — contents fixed at creation
- Hashable (if elements are) — usable as dict keys / set members
- Slightly faster, smaller memory footprint
- Signals "this is a fixed record"
list
queue = [3, 7]
- Mutable — append, remove, sort in place
- Not hashable — can't be a dict key
- Built for growth and iteration
- Signals "this collection will change"
append, sort,
or filter it, it's a list.
Argument passing, mutability, and the def f(x=[]) trap
Python passes references to objects, never copies and never "variable slots." Whether a function's edits are visible to the caller depends entirely on whether the object itself is mutable.
int, str, tuple — "modifying" inside a
function creates a new object and rebinds the local name. The caller's
variable is untouched.
list, dict, set, custom objects — in-place
mutation (.append(), item assignment) is visible to the caller, because
both names point at the same object.
The trap, live
Default argument values are evaluated once, at definition time — not on every call. A mutable default becomes a single object shared by every call that omits the argument. Press Call a few times below.
The fix
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
def add_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
Iterators vs. generators — what yield actually changes
Every generator is an iterator, but not every iterator is a generator. An
iterator is any object implementing __iter__ and
__next__. A generator is the easy way to get one — write a
function with yield, and Python builds the iterator for you.
Step through a generator's lifecycle
This function is paused, not finished, every time it hits yield. Click
Next to step through calls to next().
countdown(3) runs none of the body — it
returns a generator object. Each next() resumes execution exactly
where it paused, with all local variables (here, n) intact, until
either the next yield or a StopIteration.
Why pipelines reach for generators over full lists
At pipeline scale, the question isn't "is this faster" — it's "does this fit in memory, and can the next stage start before this one finishes?" A generator yields one record at a time; a list materializes everything before anything downstream can begin.
len() or indexing/slicingitertools for declarative pipelinesConcurrency vs. parallelism — and what the GIL means for CPU-bound work
Concurrency is structure: multiple tasks in progress, interleaved. Parallelism is execution: multiple tasks running at the literal same instant, on multiple cores. A single core can be concurrent but never parallel.
Threading I/O-bound
GIL released while waiting on I/O → real overlap.
Threading CPU-bound
GIL forces strict turn-taking → no real speedup.
Multiprocessing CPU-bound
Separate interpreters, separate GILs → true parallelism.
threading or asynciomultiprocessing or C-extensionsasyncio + a process poolMiddleware vs. context processors
Both let you inject logic outside the view — but they operate at different layers of the request lifecycle, and only one of them runs for non-template responses.
Middleware
- Runs for every request — APIs, redirects, downloads, everything
- Can short-circuit before the view ever runs
- Can modify the response, or catch exceptions
- Right place for: auth, tenant resolution, logging, rate limiting
Context Processor
- Runs only when a template is rendered
- Adds variables to every template's context automatically
- No effect on JSON/API responses or redirects
- Right place for: display-only values (e.g. tenant name in a navbar)
request.tenant as the
source of truth (available everywhere, including APIs); a context processor simply
exposes that already-resolved value for display in templates.Where does request.tenant belong?
In custom middleware, placed early in the stack — resolving the tenant from the subdomain, a header, or a token claim before any view code runs.
request.tenant already exists.request.class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
subdomain = request.get_host().split('.')[0]
tenant = Tenant.objects.filter(
schema_name=subdomain, is_active=True
).first()
if tenant is None:
return HttpResponseNotFound("Unknown tenant")
request.tenant = tenant
return self.get_response(request)
A global enforcement plan for tenant isolation
No single mechanism is enough — the plan is defense in depth. Each layer below catches what the layer above might miss.
request.tenant for every request.get_queryset() overridden once, inherited by every viewset.obj.tenant == request.tenant even if the queryset was bypassed.tenant is read-only; never accepted from client input.get_queryset()) protects the standard
CRUD paths but not custom @action methods, raw queries, or future
mistakes like Model.objects.get(pk=pk). The combination is what makes
tenant-safety the default, not an opt-in.Where tenant resolution actually runs
Middleware, authentication classes, permission classes and viewsets could all theoretically know about the tenant — but only one of them should be the resolver. The rest should consume what it produces.
request.tenantrequest.tenant?request.tenantWhy not the alternatives as the primary resolver?
- Authentication classes — DRF-only, tied to auth flow, awkward for short-circuiting non-API requests like the admin or media.
- Permission classes — should check tenant membership, not discover the tenant; conflating the two makes them harder to reuse and test.
- Viewsets — would duplicate logic per-viewset and run too late, after routing and auth have already happened.
Auto-scoping every viewset action — without repeating yourself
Every DRF action — list, retrieve, update,
partial_update, destroy — funnels through
get_queryset() (and get_object(), which calls it). Override it
once in a base class, and every action inherits the tenant filter.
class TenantScopedQuerySetMixin:
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(tenant=self.request.tenant)
class BaseTenantViewSet(TenantScopedQuerySetMixin, viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTenantObjectOwner]
def perform_create(self, serializer):
# Force the tenant — never trust client input
serializer.save(tenant=self.request.tenant)
What each action gets, automatically
filter_queryset(get_queryset()) — already tenant-filtered.get_object() → get_queryset().get(pk=...). Another tenant's object simply isn't in the queryset → 404, not 403 — no existence leak.perform_create() sets tenant server-side. Serializer marks it read-only.What if someone just guesses an object ID?
Queryset scoping is the first wall — but it should never be the only one. Three layers, each catching what the previous one might miss:
class IsTenantObjectOwner(BasePermission):
def has_object_permission(self, request, view, obj):
return getattr(obj, 'tenant_id', None) == request.tenant.id
Testing it
@actions — using Tenant B's object IDs.TenantIsolationTestCase mixin — every new endpoint inherits the check.