Python Interview Questions

Descriptors and Attribute Access

questions
Scroll to track progress

A model layer uses custom descriptors for lazy loading attributes from a cache. Benchmark shows descriptor `__get__` is called 10M times/hour. At scale, descriptor overhead becomes noticeable (1 microsecond per call = 10 seconds/hour CPU). How do you reduce descriptor overhead?

Descriptors are called on every attribute access: `obj.attr` triggers `descriptor.__get__()`. At 10M calls, even microseconds add up. Solutions: (1) bypass descriptor for hot paths: cache the result locally or inline critical paths to avoid repeated lookups. Store in local variable: `cached_val = obj.attr; ... use cached_val 1000 times`. (2) use `__slots__` to reduce attribute lookup overhead—Python skips descriptor protocol for slotted attributes, direct array access. (3) replace descriptors with properties (`@property`) for simple cases—similar overhead but cleaner semantics, less code. (4) profile with `py-spy` to confirm descriptors are bottleneck, not something else. (5) batch operations: instead of `obj.attr` in a loop, load attributes once into a local var or list. (6) use C extensions (`@property` in C via Cython) for ultra-hot descriptors—10-100x faster than Python. Measure: instrument descriptor `__get__` with timing: `t = time.perf_counter(); ... return result` inside descriptor. Track per-call overhead. If 10M calls is unavoidable, 1µs per call is expected; focus on reducing call count. For lazy loading from cache: batch fetches—load 100 attributes in 1 cache lookup instead of 100 lookups. Use descriptor for initialization only, then replace with direct attribute.

Follow-up: How do you implement a descriptor that works efficiently with `__slots__`?

A validation descriptor intercepts all writes via `__set__`, runs expensive checks (regex, schema validation), and rejects invalid values. On a form with 100 fields, each being validated, form submission takes 5 seconds. Validation logic is sound but slow. How do you optimize without losing validation?

Validation in `__set__` runs on every write. With 100 fields * complex validation, overhead is significant. Solutions: (1) lazy validation: defer validation to explicit call (e.g., `obj.validate()`) instead of on every write. Store value immediately, validate when needed. (2) cache validation results: if same value is written multiple times, skip re-validation. Use `@lru_cache` decorator on validation function with cache key (field name, value). (3) batch validation: collect all writes, validate all at once in a single pass (e.g., form submission, not field-by-field). (4) optimize validation logic: if regex takes 100ms per field, compile regex once: `re.compile(pattern)` at descriptor creation, reuse for all instances. (5) use database constraints instead of Python validation: let the database reject invalid data on insert. Python validation becomes advisory (fast, no DB call). (6) async validation: if validation is I/O-bound (API call, DB query), use async I/O to overlap validation of multiple fields. For forms: consider client-side validation (JavaScript) to catch most errors before server round-trip. Server-side validation (Python) focuses on security (never trust client). Measure: profile validation function separately (`timeit` on regex, schema checks). If regex is 80% of time, optimize regex or cache. If validation logic is complex, consider moving to database or service layer (not descriptor). Test: ensure validation still works after optimization (regression test with invalid/valid values).

Follow-up: How do you implement deferred validation that still catches errors before data is persisted?

You have a descriptor that provides computed properties (e.g., `full_name` = `first_name` + ' ' + `last_name`). With millions of instances and frequent access, descriptor overhead is noticeable. Should you cache computed values or compute on-the-fly?

Computing vs caching trade-off: compute is ~1µs (fast), but with 10M accesses/hour = 10 seconds CPU. Caching adds memory (store computed value) but is O(1) lookup. Solutions: (1) use `functools.cached_property` (Python 3.8+): first access computes, subsequent accesses return cached value. On write to underlying fields, invalidate cache. (2) use weakref to avoid holding stale cache: if instance is modified, cache becomes invalid. Store cache in WeakValueDictionary. (3) property with local cache: `@property def full_name(self): if self._full_name_cache is None: self._full_name_cache = self.first_name + ' ' + self.last_name; return self._full_name_cache`. (4) invalidation strategy: if `first_name` is updated, manually call `obj._invalidate_full_name()` to clear cache. (5) for truly hot properties accessed millions of times, consider database computed columns or materialized views (computed once at write time, read is free). Measure: profile property access. If 10% of time is descriptor overhead, caching saves 10% CPU. For millions of instances, even 10% is worth pursuing. Test: ensure cache is invalidated correctly when underlying values change. If cache becomes stale, computed value is wrong—regression risk. For reads >> writes, caching is always worth it. For reads ≈ writes, compute on-the-fly might be better (no invalidation logic).

Follow-up: How do you safely invalidate cached properties when inheritance and multiple dependencies are involved?

A descriptor implements a "readonly" attribute: `__get__` returns value, `__set__` raises error. After deployment, code tries to set a readonly attribute and raises. During development, this error was caught, but in production (different code path), it's unhandled, crashing the service. How do you make errors visible earlier?

Descriptor `__set__` errors are only visible at runtime when code tries to write. Development and production use different code paths, so some write attempts aren't tested. Solutions: (1) use type hints + mypy: mark attribute as readonly in type annotations, mypy catches assignment attempts at static analysis time (before runtime). (2) use `@property` with no setter: Python raises AttributeError at runtime, clearer intent than descriptor. (3) use `__slots__` with explicit attributes, no descriptor magic—easier for static analysis. (4) use dataclass with `frozen=True`: prevents all writes after initialization, enforced by Python. (5) code coverage: ensure all code paths are tested in CI. If dev doesn't hit a code path, production will. Use branch coverage (`coverage.py`) to find untested paths. (6) linting: use `pylint` or `pyright` to warn about writes to readonly attributes. For production: leverage static analysis (mypy, pylint) to catch attribute errors before deployment. Test: mark attribute as readonly, attempt write in test, verify error is raised. Include this test in CI to catch regressions. For critical data: use database-level constraints (not just Python) to prevent invalid writes—defense in depth.

Follow-up: How do you implement a "write-once" descriptor that allows initial assignment but prevents modifications?

A descriptor is used for thread-safe attribute access: `__get__` and `__set__` acquire a lock to ensure atomicity. With high concurrency (100 threads accessing the same object), lock contention spikes and throughput drops 50%. How do you scale thread-safe descriptors?

Lock-per-descriptor is bottleneck: all 100 threads contend on 1 lock. Solutions: (1) sharding: instead of 1 lock per descriptor, use dict of locks keyed by instance ID or attribute name. Different threads acquire different locks, reducing contention. (2) use `threading.RWLock` (from `readerwriterlock` package): if reads >> writes, RWLock allows concurrent readers, only writers block. (3) use thread-local storage: `threading.local()` for per-thread attributes, no locking needed. Each thread has its own copy. Downside: data isn't shared across threads (design constraint). (4) use lock-free data structures: `queue.Queue` is thread-safe without explicit locks. For attributes, consider event-based updates (Observable pattern) instead of locks. (5) move to async: single event loop + async access eliminates threading overhead entirely. No locks needed. (6) use atomic operations: CPython's GIL provides some atomicity (simple operations like `x = y` are atomic), but complex operations need explicit locking. Measure: profile lock contention with `threading.stack_size()` instrumentation or `py-spy`. If threads spend >10% time waiting for locks, contention is high. For 100 threads on 1 descriptor: sharding to 10 locks (10 threads per lock) reduces contention 10x. Test: benchmark throughput with N locks, find sweet spot (usually N = thread_count / 10 to thread_count / 2).

Follow-up: How do you prevent deadlock when sharding descriptors with multiple locks?

A descriptor's `__get__` method makes a network call (e.g., fetches data from cache server) to retrieve attribute value. On high-traffic pages with 10 concurrent requests, each accessing the same attribute 5 times, you make 50 redundant network calls. Caching helps but is complex. How do you deduplicate concurrent requests?

Multiple concurrent requests fetch the same data independently, causing redundant I/O. Solution: "request deduplication" or "request coalescing"—if a request is in-flight, wait for its result instead of starting a new one. Implementation: (1) use `asyncio` with caching decorator: `@functools.lru_cache` + `async def get_value()`. First coroutine fetches, others await same task. (2) use `threading.Condition` for threading: first thread fetches, others wait on condition variable. When fetch completes, notify all waiters. (3) use `dataclasses.cached_property` (Python 3.8+): first access computes, subsequent accesses return cached value without re-triggering fetch. (4) implement fetch-then-cache: before fetching, check if in-progress: if yes, wait; if no, mark in-progress, fetch, cache, notify waiters. (5) use cache backend (Redis) with locks: `redis.lock(key)` ensures only 1 client fetches at a time. Measure: monitor network call frequency. If 50 calls for same data, deduplication is needed. After deduplication: 1-2 calls per data item. For 10 concurrent requests accessing same attribute 5x each: deduplication reduces calls from 50 to 5 (10x reduction). Test: measure cache hit rate and network calls with/without deduplication. For HTTP services: HTTP caching headers (ETag, Cache-Control) achieve similar deduplication at framework level.

Follow-up: How do you handle cache stampede when cached value expires and many requests simultaneously fetch new value?

A descriptor transforms attribute values on access (e.g., decrypt, deserialize). The transformation is expensive (AES decryption = 1ms per access). Accessing `obj.secret_field` repeatedly in a loop takes 10x longer than expected. How do you optimize without storing unencrypted data?

Descriptor transformation on every access is expensive. Solutions: (1) local caching: store transformed value in local variable in the function: `secret = obj.secret_field; print(secret); print(secret)` uses cached value on second access. (2) request-scoped cache: for HTTP requests, cache decrypted value in request context for duration of request. Use context vars (`contextvars.ContextVar`) to store cache. On next request, cache is cleared. (3) TTL-based cache: cache decrypted value for N seconds, re-decrypt if cache expires. Use `functools.cached_property` with manual invalidation timer. (4) lazy decryption: store encrypted value, only decrypt if actually accessed (descriptor pattern). Store decrypted version once decrypted. (5) hybrid: store plaintext in-memory for read-heavy workloads (more risk), or fully encrypted for write-heavy (more overhead). Measure: profile decryption time. If 1ms per access and loop accesses 100 times, 100ms total. Local caching reduces to 1ms (first access) + 100 more accesses as O(1) lookups = 1ms total. Huge win. Test: ensure security properties are maintained. If caching stores plaintext in memory, ensure memory isn't dumped to disk or logs. For sensitive data (passwords, PII), caching is risky—only cache for duration of operation, then clear. Consider: do you need to access the same field repeatedly? If yes, cache. If no, decrypt-per-access is safer (no plaintext sitting around).

Follow-up: How do you implement secure caching of decrypted values that ensures they're cleared when no longer needed?

A descriptor implements property validation with `__set__`: it checks constraints (type, range, regex) and raises if invalid. For a data migration, you need to temporarily accept invalid values, then fix them gradually. Adding an `allow_invalid` flag to every descriptor is tedious. How do you override validation globally?

Overriding descriptor behavior globally requires intercepting calls without modifying every descriptor. Solutions: (1) context manager: use `contextvars.ContextVar` to set a flag, check in descriptor: `if _ALLOW_INVALID.get(): accept value without validation`. Example: `with allow_invalid_data(): obj.attr = invalid_value`. (2) monkey-patch descriptor class: replace `__set__` method on descriptor base class with debug version that logs instead of raising. Risky but effective for temporary overrides. (3) use a wrapper class: instead of modifying descriptor, create a wrapper that bypasses validation: `obj._bypass_descriptor['attr'] = invalid_value` stores directly, skipping descriptor. (4) implement `__setattr__` on model to intercept all writes, check context flag. (5) use database transactions: wrap migration in transaction, disable constraints at DB level, validate after migration. Measure: determine scope of override—if only for migration, time-limit with context manager. If permanent, reconsider design. Best practice: context managers are cleanest: `with allow_invalid_data(): migrate_data()` ensures flag is scoped. After migration, validation resumes. Test: verify validation works after migration (flag is cleared). Ensure no production code accidentally uses invalid data. For data fixes: batch update scripts (not production code) should use overrides, with explicit comments. Avoid leaving overrides in production code.

Follow-up: How do you audit and log all uses of validation bypasses to ensure they're temporary and removed after migration?

Want to go deeper?