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.

12 Topics
06 Python Core
06 Django / DRF
Start reading ↓
01

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"
→
Rule of thumb: if you'd be uncomfortable with someone mutating it downstream — coordinates, RGB values, a function's multi-value return, a dict key — make it a tuple. If you're going to append, sort, or filter it, it's a list.
02

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.

Immutable

int, str, tuple — "modifying" inside a function creates a new object and rebinds the local name. The caller's variable is untouched.

Mutable

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.

def add_item(item, bucket=[]):
Call 1
add_item('a')
Call 2
add_item('b')
Call 3
add_item('c')
Heap — the shared default object
id 0x7f3a… list object
[ ]

Every call that omits bucket binds to this exact object.

Run a call to see what happens to bucket and what gets returned.

The fix

✗ Shared mutable default
def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket
✓ Sentinel + fresh object per call
def add_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket
⚠
Why it bites in production: in long-running processes (a Django view, a Celery worker, an async loop) the same function object is reused across thousands of calls. A mutable default becomes accidental, hidden global state that accumulates silently — and the bug only shows up after the second call, which rarely happens in a quick manual test.
03

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

def countdown(n):
    while n > 0:
        yield n
        n -= 1
    print("done")
state not started
n 3
last next() returned —
→
Calling 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.
04

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.

📄
read_rows()
→
🔧
parse()
→
🧪
filter()
→
💾
write()
Each stage pulls one item only when the next stage asks for it — nothing is buffered in full.
Generators win
Trade-offs to weigh
Constant memory regardless of dataset size
Single-pass only — re-iterating means recreating the generator
Pipeline stages compose naturally (read → parse → filter → write)
No len() or indexing/slicing
Downstream work can start before upstream finishes
Errors surface lazily — harder stack traces
Plays well with itertools for declarative pipelines
Open resources (files, cursors) need explicit cleanup
05

Concurrency 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

Thread A
Thread B

GIL released while waiting on I/O → real overlap.

Threading CPU-bound

Thread A
Thread B

GIL forces strict turn-taking → no real speedup.

Multiprocessing CPU-bound

Process A
Process B

Separate interpreters, separate GILs → true parallelism.

⏳
I/O-bound
network calls, DB queries, file I/O
→ threading or asyncio
🧮
CPU-bound
tight loops, numeric crunching, image processing
→ multiprocessing or C-extensions
⚖️
Mixed
orchestration + heavy compute
→ asyncio + a process pool
06

Middleware 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 request in
View business logic
Context Processor only if rendering a template
Middleware response out
🛡️

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)
→
They're often layered: middleware resolves request.tenant as the source of truth (available everywhere, including APIs); a context processor simply exposes that already-resolved value for display in templates.
07

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.

1
Single point of resolution. One place owns "which tenant is this?" — no duplicated logic across views and serializers.
2
Runs for every request. Views, viewsets, permissions and serializers can all assume request.tenant already exists.
3
Fails fast. Unknown or inactive tenant → return 404/400 immediately, before any DB work happens.
4
Works everywhere. Function views, class views, DRF viewsets, signals — all see the same attribute on 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)
08

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.

01
Middleware — resolution
Resolves and attaches request.tenant for every request.
02
Manager / queryset — data access
Default queryset is filtered by tenant, so "forgetting to filter" is structurally hard.
03
Base ViewSet — view layer
get_queryset() overridden once, inherited by every viewset.
04
Object-level permission — authorization
Re-checks obj.tenant == request.tenant even if the queryset was bypassed.
05
Serializer — write safeguard
tenant is read-only; never accepted from client input.
⚠
A single layer (e.g. only 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.
09

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.

Middleware
Resolves
subdomain / header / token → request.tenant
↓ all requests, every endpoint
Authentication
Identifies
who is the user, within this tenant?
↓
Permissions
Authorizes
does this user/object belong to request.tenant?
↓
ViewSet / queryset
Scopes
all data access filtered by request.tenant

Why 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.
10

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

list
Calls filter_queryset(get_queryset()) — already tenant-filtered.
retrieve / update / destroy
Use get_object() → get_queryset().get(pk=...). Another tenant's object simply isn't in the queryset → 404, not 403 — no existence leak.
create
perform_create() sets tenant server-side. Serializer marks it read-only.
11

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

🏢🏢
Seed data for two tenants (A and B), authenticate as a Tenant A user.
🎯
Hit every action — including custom @actions — using Tenant B's object IDs.
✅
Assert 404/403 on cross-tenant access, and that writes can't relink to Tenant B via a guessed FK.
🔁
Wrap it in a reusable TenantIsolationTestCase mixin — every new endpoint inherits the check.