SammyFold

SammyFold AI

👋 Hi! Looking to build something great? Ask me about our services, pricing, or how we can help your business.

Dev Concepts

12 Python & Django Concepts That Separate Junior from Senior Developers

Tuples, the mutable-default trap, generators, the GIL, middleware and multi-tenant DRF architecture — explained with everyday examples, runnable code and interview-ready answers.

Some interview questions look simple — "what's the difference between a tuple and a list?" — but the way you answer shows whether you've only read about Python or have built real systems with it. This post walks through 12 of those questions, from Python's core behaviour to how professional Django apps keep each customer's data separate.

Every topic follows the same pattern:

  • In plain English — an everyday comparison, so anyone can follow.
  • The code — short examples you can run yourself.
  • The senior answer — the one or two sentences that show real understanding.

New to programming? Read Part 1 first; it builds the foundations. Part 2 is about architecture, so take it slowly and come back to it.

Part 1 — Python core

1. Tuple vs list

In plain English: a tuple is like a printed receipt — once it's printed, the items don't change. A list is like a shopping basket — you keep adding, removing and rearranging things.

point = (3, 7)        # tuple — round brackets, fixed
basket = [3, 7]       # list  — square brackets, changeable

basket.append(10)     # fine → [3, 7, 10]
point.append(10)      # AttributeError: tuples can't be changed
Tuple List
Can change after creation? No (immutable) Yes (mutable)
Can be a dictionary key? Yes, if its items can No
Memory & speed Slightly smaller and faster Built for growing
What it tells other developers "This is a fixed record" "This collection will change"

Because tuples can't change, they can be used as dictionary keys — handy for things like map coordinates:

city_at = {(5.6037, -0.1870): 'Accra', (6.6885, -1.6244): 'Kumasi'}

Senior answer: "The real difference is mutability and intent. I use a tuple for a fixed record — coordinates, RGB colours, multiple return values, dictionary keys — and a list when I'll append, sort or filter it."

2. How Python passes arguments (and why your list changed)

In plain English: a variable in Python is a name tag, not a box. When you pass a variable into a function, the function gets another name tag on the same object — not a copy.

What happens next depends on whether that object can change:

def add_one(number):
    number = number + 1        # creates a NEW int; only the local tag moves
    return number

def add_item(items):
    items.append('mango')      # changes the SAME list the caller has

count = 5
add_one(count)
print(count)                   # 5  — ints are immutable, the caller is untouched

fruits = ['apple']
add_item(fruits)
print(fruits)                  # ['apple', 'mango'] — the caller sees the change
  • Immutable objects (int, str, tuple): "changing" them inside a function actually creates a new object, so the caller's value stays the same.
  • Mutable objects (list, dict, set, your own classes): changing them in place is visible to the caller, because both names point at the same object.

Senior answer: "Python passes references to objects — sometimes called call by sharing. Whether the caller sees a change depends on whether the object is mutable and whether the function mutates it in place or rebinds the name."

3. The shared default trap: def f(x=[])

This is one of the most famous Python bugs. Look at this function:

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

You'd expect every call to start with an empty bucket. Here's what actually happens:

add_item('a')    # ['a']
add_item('b')    # ['a', 'b']        ← surprise!
add_item('c')    # ['a', 'b', 'c']   ← it keeps growing

In plain English: the default [] is created once, when Python first reads the def line — not every time you call the function. It's like a restaurant that puts one shared plate on the counter for every customer who doesn't bring their own. Everyone's food piles onto the same plate.

The fix: use None as a placeholder and create a fresh list inside:

def add_item(item, bucket=None):
    if bucket is None:
        bucket = []          # a brand-new list on every call
    bucket.append(item)
    return bucket

Why this hurts in real apps: a Django view or background worker runs the same function thousands of times without restarting. The shared list quietly grows and leaks data between users — and a quick manual test (which calls it only once) never catches it.

Senior answer: "Default values are evaluated once at definition time, so a mutable default becomes hidden shared state. I use None as a sentinel and create the object inside the function."

4. Iterators vs generators — what yield really does

In plain English: an iterator is anything that can hand you items one at a time — like a ticket machine giving out the next number each time you press the button. A generator is the easy way to build one: write a normal function, but use yield instead of return.

def countdown(n):
    while n > 0:
        yield n          # hand out n, then PAUSE right here
        n -= 1
    print("done")

Let's step through it:

gen = countdown(3)   # nothing runs yet — you just get a generator object

next(gen)            # 3   (runs until the first yield, then pauses)
next(gen)            # 2   (resumes after yield, n is remembered)
next(gen)            # 1
next(gen)            # prints "done", then raises StopIteration — it's finished

The magic: each next() continues exactly where it paused, with all its local variables (like n) still intact. A for loop simply calls next() for you and stops at StopIteration:

for number in countdown(3):
    print(number)    # 3, 2, 1, then "done"

Senior answer: "Every generator is an iterator, but not every iterator is a generator. An iterator is any object with __iter__ and __next__; a generator function builds one for me and keeps its state paused between next() calls."

5. Why big data pipelines use generators

In plain English: imagine moving 1 million bricks. A list is like loading all of them onto a giant truck before anyone can start building. A generator is a conveyor belt — each brick goes straight to the builder, and building starts immediately.

def read_rows(path):
    with open(path) as f:
        for line in f:                 # reads one line at a time
            yield line

def parse(rows):
    for row in rows:
        yield row.strip().split(',')

def only_paid(records):
    for record in records:
        if record[2] == 'paid':
            yield record

# Nothing is read until the loop asks for the next item — memory stays tiny
for order in only_paid(parse(read_rows('orders.csv'))):
    save(order)
Generators win Trade-offs to remember
Same small memory for 1 thousand or 100 million rows You can only loop through once (make a new one to repeat)
Each stage plugs into the next No len(), no indexing like rows[5]
Work starts before reading finishes Errors appear later, when the item is reached
Works beautifully with itertools Open files or database cursors need proper closing

Senior answer: "At scale the question isn't speed, it's memory and latency. Generators give constant memory and let downstream stages start immediately, at the cost of single-pass iteration and no random access."

6. Concurrency vs parallelism (and the GIL)

In plain English:

  • Concurrency is one chef cooking several dishes — stirring one pot while another simmers. Many tasks are in progress, but the chef does one thing at a time.
  • Parallelism is several chefs cooking at the same moment — real simultaneous work, using several CPU cores.

Python (the standard version, CPython) has the GIL — the Global Interpreter Lock. Think of it as a single kitchen knife: only one thread can run Python code at any instant.

  • When a thread is waiting (for a website, the database, a file), it puts the knife down, so other threads can work. → Threads do help.
  • When a thread is calculating non-stop, it never puts the knife down. → Threads don't speed things up.
Kind of work Examples Use
I/O-bound (mostly waiting) API calls, database queries, reading files threading or asyncio
CPU-bound (mostly calculating) Image processing, big maths loops multiprocessing (separate processes, each with its own GIL) or C extensions like NumPy
Mixed Orchestrating jobs plus heavy computing asyncio plus a process pool
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

with ThreadPoolExecutor() as pool:            # many downloads at once (waiting-heavy)
    pages = list(pool.map(download, urls))

with ProcessPoolExecutor() as pool:           # heavy maths on every CPU core
    results = list(pool.map(crunch_numbers, datasets))

Good to know: newer Python versions (3.13 and later) offer an optional "free-threaded" build without the GIL, but most production systems still run the standard build — so this answer still matters in interviews.

Senior answer: "Concurrency is about structure, parallelism is about simultaneous execution. Because of the GIL, threads help I/O-bound work but not CPU-bound work; for CPU-bound work I use multiprocessing or native extensions."

Part 2 — Django & DRF: keeping customers' data apart

First, what is "multi-tenancy"?

Imagine an apartment building. One building (your app), one set of pipes and electricity (your code and database), but many families (tenants) living in separate flats. Each family must only ever get into their own flat.

A multi-tenant app is one system serving many companies — for example a school-management app used by 50 schools, where School A must never see School B's students. The next six questions are about making that separation automatic and reliable.

7. Middleware vs context processors

Both run "around" your views, but at different moments:

  • Middleware runs on every request — web pages, APIs, redirects, downloads. It can stop a request before the view even starts. Think of it as the building's security gate.
  • A context processor only runs when a template (HTML page) is being drawn, adding variables to every page — like the welcome sign in the lobby. It does nothing for JSON APIs or redirects.
# A context processor: adds the tenant's name to every HTML page
def tenant_name(request):
    tenant = getattr(request, 'tenant', None)
    return {'tenant_name': tenant.name if tenant else ''}
Middleware Context processor
Runs for Every request Only template-rendered pages
Can block a request? Yes No
Affects APIs (JSON)? Yes No
Best for Authentication, tenant lookup, logging, rate limits Display values like a logo or name in the navbar

Senior answer: "Middleware is the source of truth and runs everywhere; a context processor only exposes already-resolved values to templates. They're often layered: middleware sets request.tenant, a context processor displays it."

8. Where should request.tenant be set?

In custom middleware, early in the list. It works out which tenant the request belongs to — from the subdomain (schoola.myapp.com), a header, or a login token — before any view runs.

from django.http import HttpResponseNotFound

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        subdomain = request.get_host().split('.')[0]          # "schoola" from schoola.myapp.com
        tenant = Tenant.objects.filter(subdomain=subdomain, is_active=True).first()
        if tenant is None:
            return HttpResponseNotFound('Unknown tenant')      # stop here, no data touched
        request.tenant = tenant                                # every view can now use it
        return self.get_response(request)

Why middleware?

  1. One place decides "which tenant is this?" — no copy-pasted logic in every view.
  2. It runs for every request, so every view, permission and serializer can rely on request.tenant.
  3. It fails fast — an unknown or inactive tenant is rejected before any database work.
  4. It works everywhere — function views, class views and DRF viewsets all see the same attribute.

9. A global plan: defence in depth

In plain English: a bank doesn't rely on one lock. It has a gate, a guard, a vault door and cameras. If one fails, the next one catches the problem. Tenant safety works the same way — five layers:

Layer Job Catches
1. Middleware Finds the tenant, sets request.tenant Unknown tenants
2. Model manager / queryset Makes "filter by tenant" the easy default Developers forgetting to filter
3. Base ViewSet Filters get_queryset() once for every API view Standard list / detail / update / delete
4. Object permission Re-checks obj.tenant == request.tenant Custom actions that skip the queryset
5. Serializer tenant is read-only, set by the server Clients trying to send another tenant's ID

A queryset helper keeps the "right way" short and obvious:

class TenantQuerySet(models.QuerySet):
    def for_tenant(self, tenant):
        return self.filter(tenant=tenant)

class Order(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    # ...
    objects = TenantQuerySet.as_manager()

Order.objects.for_tenant(request.tenant)       # the obvious, safe way to query

Why one layer isn't enough: filtering only in get_queryset() protects the standard API routes, but not a custom @action, a raw query, or a future developer writing Order.objects.get(pk=pk). Layers make safety the default, not something people must remember.

10. Which layer should find the tenant?

Several parts of Django and DRF could work out the tenant, but only one should be the resolver; the rest just use the answer:

  1. Middleware → resolves — "which tenant is this request for?" (subdomain, header or token → request.tenant)
  2. Authentication → identifies — "who is this user, within this tenant?"
  3. Permissions → authorises — "is this user, and this object, part of request.tenant?"
  4. ViewSet / queryset → scopes — "only show data that belongs to request.tenant."

Why not the others as the resolver?

  • Authentication classes only exist in DRF, so the admin, normal pages and media files would miss out.
  • Permission classes should check membership, not discover the tenant — mixing the two makes them hard to reuse and test.
  • ViewSets would repeat the logic in every viewset, and they run too late, after routing and authentication.

11. Scope every API action automatically — write it once

In Django REST Framework, the actions list, retrieve, update, partial_update and destroy all get their data through one method: get_queryset() (and get_object(), which calls it). So if you filter it once in a base class, every viewset that inherits from it is protected.

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated

class TenantScopedQuerySetMixin:
    def get_queryset(self):
        return super().get_queryset().filter(tenant=self.request.tenant)

class BaseTenantViewSet(TenantScopedQuerySetMixin, viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated, IsTenantObjectOwner]

    def perform_create(self, serializer):
        serializer.save(tenant=self.request.tenant)   # the server decides the tenant, never the client

class OrderViewSet(BaseTenantViewSet):                # protected automatically
    queryset = Order.objects.all()
    serializer_class = OrderSerializer

What each action gets for free:

  • list — already filtered to the current tenant.
  • retrieve / update / destroy — another tenant's object simply isn't in the queryset, so the user gets 404 Not Found rather than 403 Forbidden. That's deliberate: a 403 would confirm the object exists, which leaks information.
  • create — the tenant is set on the server; the serializer marks it read-only.

12. "What if someone guesses an ID?"

In plain English: a user at School A changes the address from /orders/12/ to /orders/123/, hoping to see School B's order. Three walls stop them:

Wall 1 — queryset scoping. Order 123 isn't in School A's queryset, so get_object() returns 404 before anything else runs. To School A, it doesn't exist.

Wall 2 — object-level permission. A safety net for any code path that skips the queryset (custom actions, manual lookups):

from rest_framework.permissions import BasePermission

class IsTenantObjectOwner(BasePermission):
    def has_object_permission(self, request, view, obj):
        return getattr(obj, 'tenant_id', None) == request.tenant.id

Wall 3 — serializer validation. tenant is read-only, and any linked record must belong to the same tenant, so a guessed ID can't connect data across schools:

class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ['id', 'project', 'amount', 'tenant']
        read_only_fields = ['tenant']

    def validate_project(self, project):
        if project.tenant_id != self.context['request'].tenant.id:
            raise serializers.ValidationError('Project not found.')
        return project

Prove it with a test

Don't just trust it — test it on every endpoint:

class TenantIsolationTests(APITestCase):
    def setUp(self):
        self.school_a = Tenant.objects.create(name='School A', subdomain='a')
        self.school_b = Tenant.objects.create(name='School B', subdomain='b')
        self.other_order = Order.objects.create(tenant=self.school_b, amount=100)
        self.client.force_authenticate(user=make_user(tenant=self.school_a))

    def test_cannot_read_another_tenants_order(self):
        response = self.client.get(f'/api/orders/{self.other_order.id}/', HTTP_HOST='a.myapp.com')
        self.assertEqual(response.status_code, 404)

Seed two tenants, log in as a user from one, then hit every action — including custom @actions — with the other tenant's IDs, and check you always get 404/403 and can never link records across tenants. Put the checks in a reusable test mixin so every new endpoint inherits them.

Quick-fire practice

Why can a tuple be a dictionary key but a list can't?

Dictionary keys must be hashable, which requires that the value never changes. Tuples are immutable (as long as their items are too), so they're hashable; lists can change, so they aren't.

What does add_item('x') return the second time if the default is bucket=[]?

['x', 'x'] — the default list was created once and is shared between calls. Use bucket=None and create the list inside the function.

What happens when you call a generator function?

None of its body runs. You get a generator object; the code only runs (up to the next yield) each time next() is called.

You need to resize 10,000 images in Python. Threads or processes?

Processes (multiprocessing / ProcessPoolExecutor). Resizing is CPU-bound, and the GIL stops threads from running Python code in parallel.

Why return 404 instead of 403 for another tenant's object?

A 403 confirms that the object exists, which leaks information. A 404 makes the other tenant's data indistinguishable from data that doesn't exist.


Read next: Python & Django interview questions explained for beginners and Django security explained simply.

Based on Mr. Agyemang Samuel's interactive engineering notes — open them to play with the live diagrams. Building a multi-tenant SaaS for several clients or branches? Talk to SammyFold.

Agyemang SamuelSammyFold Technology · Building software for African businesses. Got a question about this post? Use the Chat tab or contact us.

Install SammyFold Technology

Launch like a native app — no browser needed
Reopen pages you've visited, even when offline
Faster, full-screen experience on any device
Start a project or leave a review in one tap
Install on your laptop / PC

Works in Chrome and Edge on Windows, Mac and Linux.

1 Look for the install icon at the right end of your browser's address bar
2 Or open the browser menu ⋮ and choose "Install SammyFold Technology…"
3 Click "Install" to confirm — the app opens instantly
Add to iPhone / iPad

On another device? Open sammyfoldtech.com in Safari on your iPhone first.

1 Tap the Share button at the bottom of Safari
2 Scroll and tap "Add to Home Screen"
3 Tap "Add" — SammyFold is installed instantly