Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 079 / 100

Python backend interview questions: a step-by-step guide

What interviewers ask senior Python developers.

Expona Admin8 min read

When you walk into a senior Python backend interview, the interviewer isn't looking for textbook definitions—they want to know how you've solved real problems at scale. Python backend interview questions: a step-by-step guide covers the topics that separate candidates who've shipped production systems from those who haven't. We'll walk through async patterns, query optimization, scaling strategies, and security practices that actually matter in the field.

How does async work? Event loop, coroutines and cooperative scheduling

Async/await is where many Python developers stumble. You need to understand not just the syntax, but why it exists and how it differs from threading. The event loop is the heart of it: a single-threaded scheduler that runs coroutines cooperatively. When a coroutine hits an await, it yields control back to the event loop, which can then run another coroutine. This is different from OS-level threading—there's no preemption, no race conditions on shared state, and no GIL contention.

Here's a concrete scenario: you're building a web scraper that needs to fetch data from 100 URLs. If you use threading, you'll hit the GIL and context-switching overhead. With async, you're managing 100 concurrent requests in a single thread.

python
import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        return await asyncio.gather(*tasks)

# Run it
urls = ['http://example.com/' + str(i) for i in range(100)]
results = asyncio.run(fetch_all(urls))

Interviewers often ask: "What happens if you forget to await?" If you call an async function without await, you get a coroutine object that never executes. You'll see a warning like coroutine was never awaited. Another classic: "Can you use async inside a synchronous function?" No—you can't await in a non-async function. You need asyncio.run() or loop.run_until_complete() at the entry point.

The key insight is that async doesn't make your code faster—it makes I/O-bound operations concurrent. CPU-bound work still needs multiprocessing to bypass the GIL.

This question appears in almost every serious backend interview. N+1 happens when you load a parent object, then loop through its children, triggering a query for each child. With 1,000 posts, you go from 1 query to 1,001.

The solution depends on the relationship and your ORM. In Django ORM, select_related() joins the related table in a single query—use it for ForeignKey and OneToOne. prefetch_related() runs a separate query and stitches the results in Python—use it for ManyToMany and reverse ForeignKey. In SQLAlchemy, you'd use selectinload() for the same purpose.

python
from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Post(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    published_at = models.DateTimeField()

# Bad: N+1
posts = Post.objects.all()
for post in posts:
    print(post.author.name)  # Query per post

# Good: select_related for ForeignKey
posts = Post.objects.select_related('author').all()
for post in posts:
    print(post.author.name)  # No extra queries

Now consider a reverse relationship—an author with many posts:

python
from django.db.models import Prefetch

# Bad: N+1 on reverse FK
authors = Author.objects.all()
for author in authors:
    for post in author.post_set.all():  # Query per author
        print(post.title)

# Good: prefetch_related
authors = Author.objects.prefetch_related('post_set').all()
for author in authors:
    for post in author.post_set.all():  # No extra queries
        print(post.title)

A tip that impresses interviewers: you can combine them. select_related('publisher').prefetch_related('post_set') joins the publisher and prefetches posts in one go. Also, always check the query count in your tests using assertNumQueries() or Django Debug Toolbar.

How do you scale a Python API? Horizontal workers, caching, async I/O and background jobs

Scaling isn't a single lever—it's a set of levers you pull in order. A senior developer knows this hierarchy: first optimize your code, then add caching, then distribute work, then add workers.

Async I/O is your first win. If your endpoint calls three external APIs sequentially, that's wasteful. With async, you call all three concurrently:

python
import asyncio
from fastapi import FastAPI

app = FastAPI()

async def call_service_a():
    # Simulate external call
    await asyncio.sleep(1)
    return {"data": "a"}

async def call_service_b():
    await asyncio.sleep(1)
    return {"data": "b"}

async def call_service_c():
    await asyncio.sleep(1)
    return {"data": "c"}

@app.get("/combined")
async def combined():
    # Runs all 3 in parallel, completes in ~1 second instead of 3
    results = await asyncio.gather(
        call_service_a(),
        call_service_b(),
        call_service_c()
    )
    return results

Caching prevents repeated expensive operations. Use Redis for distributed caching. Cache query results, API responses, and computed values.

python
import redis
import json

cache = redis.Redis(host='localhost', port=6379, db=0)

def get_user_profile(user_id):
    cache_key = f"user:{user_id}:profile"
    
    # Check cache first
    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Cache miss: fetch from DB
    user = User.objects.get(id=user_id)
    profile = {
        'id': user.id,
        'name': user.name,
        'email': user.email,
    }
    
    # Store in cache for 1 hour
    cache.setex(cache_key, 3600, json.dumps(profile))
    return profile

Background jobs remove slow operations from the request path. Use Celery, RQ, or Dramatiq to handle email sends, file processing, and heavy computations asynchronously.

python
from celery import Celery

app = Celery('tasks', broker='redis://localhost:6379')

@app.task
def send_email(to_address, subject, body):
    # This runs in a worker, not blocking the HTTP response
    import smtplib
    # ... send email ...
    pass

# In your API:
from fastapi import FastAPI

web = FastAPI()

@web.post("/invite")
async def send_invite(email: str):
    send_email.delay(email, "Welcome", "Join us!")
    return {"status": "invite sent"}

Horizontal scaling means running multiple instances behind a load balancer. Use containers (Docker) with Kubernetes or simpler orchestration. Ensure your app is stateless—no session data stored locally, everything in Redis or a database.

Interviewers want to hear about monitoring and metrics. Use APM tools like New Relic or DataDog to track response times, error rates, and database performance. Know your bottlenecks before scaling blindly.

How do you secure an API? Validation, auth, least privilege, dependency audits

Security is where negligence becomes liability. A good answer covers multiple layers.

Input validation prevents injection attacks and malformed data. Use Pydantic for FastAPI to define strict schemas:

python
from pydantic import BaseModel, EmailStr, Field
from fastapi import FastAPI

app = FastAPI()

class CreateUser(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(..., ge=0, le=150)

@app.post("/users")
def create_user(user: CreateUser):
    # Pydantic validates type, format, and constraints
    # Invalid data is rejected automatically
    return user

Authentication verifies identity. Use industry-standard JWT or OAuth2. Don't roll your own. Store passwords with strong hashing (bcrypt, argon2), never plaintext.

python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthCredential
import jwt

security = HTTPBearer()

def verify_token(credentials: HTTPAuthCredential = Depends(security)):
    try:
        payload = jwt.decode(credentials.credentials, "secret_key", algorithms=["HS256"])
        return payload
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.get("/profile")
def get_profile(user=Depends(verify_token)):
    return {"user_id": user['sub']}

Authorization enforces least privilege—each user/service has minimum permissions needed. Role-based access control (RBAC) or attribute-based (ABAC). Check permissions on every protected endpoint.

Dependency audits prevent supply-chain attacks. Use pip audit or Snyk to scan for known vulnerabilities in your dependencies. Pin versions in requirements.txt, don't use * wildcards. Review changelogs before upgrading.

HTTPS, CORS, and headers matter too. Always use HTTPS in production. Set CORS properly—don't allow * unless necessary. Add security headers: Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security.

Logging and secrets must be handled carefully. Never log passwords, tokens, or PII. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault) for sensitive data, never hardcode them.

Common mistakes

  • Confusing async with fast: Async makes I/O concurrent, not CPU faster. If your bottleneck is database computation, async won't help.
  • Over-caching: Cache invalidation is notoriously hard. Cache liberally, but invalidate it aggressively when data changes. Stale data bugs are worse than no caching.
  • Assuming prefetch_related is always better: It runs an extra query. For single objects or small result sets, select_related can be faster.
  • Relying solely on HTTPS for security: HTTPS only encrypts in transit. Validate and sanitize input, authenticate, and authorize every request.
  • Scaling without monitoring: You scale to 10 instances, but now your database connection pool is exhausted. Know your limits before you hit them.

Key takeaways

  • Async enables concurrent I/O in a single thread via an event loop and coroutines—understand the difference between I/O-bound and CPU-bound work.
  • Eliminate N+1 queries with select_related() for joins and prefetch_related() for separate queries in Django, or selectinload() in SQLAlchemy.
  • Scale in order: optimize code, add caching (Redis), use async I/O, offload slow work to background jobs (Celery), then add horizontal workers behind a load balancer.
  • Security is layered: validate input strictly, authenticate with standard protocols (JWT/OAuth2), enforce least privilege, audit dependencies, and never hardcode secrets.
  • Interviewers test depth. Know not just the what, but the why and when. Be ready with a production war story.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles