460 Python Developer Interview Questions & Answers

154 top • 31 Amazon • 49 Google • 44 Netflix • 48 Meta • 41 NVIDIA • 47 Apple • 46 Microsoft

Python Developer icon

Questions with Detailed ExplanationsWith Detailed Explanations

(Last Updated: August 28, 2026)

61. How does method binding work for instance methods, class methods, and static methods?Language SpecificMedium

Question Details

Compare how each form is defined and accessed, what object is supplied automatically, how inheritance affects class methods, and when each form is appropriate.

Short Interview Answer (30-60 seconds)

The main difference is what Python binds automatically. An instance method receives the current object as self when accessed through an instance. A class method receives the class as cls, including a subclass used for the call. A static method receives nothing automatically. I use an instance method for object state, a class method for class aware behavior or alternative constructors, and a static method for a related helper that needs neither object nor class state.

Detailed Explanation

See the Code while reading this explanation.

Choose the method type by deciding what the operation needs. A normal function defined in a class acts as an instance method. When it is accessed through an instance, Python creates a bound method that supplies that instance as self. When it is accessed through the class, no instance is supplied, so one must be passed explicitly if the function is called.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

A class method uses the classmethod decorator. Python supplies the class as cls whether the method is accessed through the class or an instance. If a subclass performs the access, cls is that subclass. This makes class methods useful for alternative constructors and behavior that must respect inheritance.

A static method uses the staticmethod decorator. Python returns the stored function without supplying self or cls. It is useful for a helper that belongs conceptually with the class but needs no object or class state.

Binding does not copy the object or class data. Instance and class method access can create a small temporary bound method object. This cost is normally minor. Use module functions instead of static methods when the helper is not closely related to the class.

How does method binding work for instance methods, class methods, and static methods? diagram
Example

The example uses a base class and a subclass. The instance method reads data from one object through self. The class method creates an object by calling cls, so calling it through AdminUser creates an AdminUser object and preserves inherited behavior. The static method validates a value without receiving self or cls. The final class level call also shows that an instance method is not given an object automatically when it is accessed through the class.

Code
class User:
    # This value belongs to the class and can be inherited.
    role = "user"

    def __init__(self, name):
        # This value belongs to one User instance.
        self.name = name

    def describe(self):
        # Access through an instance supplies that instance as self.
        return f"{self.name} has role {self.role}"

    @classmethod
    def from_text(cls, text):
        # Access through User supplies User as cls.
        # Access through AdminUser supplies AdminUser as cls.
        cleaned_name = text.strip()
        return cls(cleaned_name)

    @staticmethod
    def is_valid_name(name):
        # Python supplies neither self nor cls here.
        return isinstance(name, str) and bool(name.strip())


class AdminUser(User):
    # The subclass replaces the inherited class value.
    role = "admin"


# The instance method receives user as self.
user = User("Maya")
print(user.describe())

# The class method receives AdminUser as cls.
admin = AdminUser.from_text("  Arjun  ")
print(type(admin).__name__)
print(admin.describe())

# The static method receives only the value passed explicitly.
print(User.is_valid_name("Lina"))
print(User.is_valid_name("   "))

# Access through the class does not supply an instance.
print(User.describe(user))
Where it is used

Instance methods are used for behavior that reads or changes one object, such as updating an order or formatting one customer record. Class methods are commonly used as alternative constructors, such as creating an object from text or a dictionary while preserving the subclass used for the call. They are also useful for operations that read class configuration. Static methods are suitable for validation, parsing, or conversion helpers that belong closely to the class but need no instance or class state. A module function is usually clearer when the helper is general and is shared by unrelated classes.

Why Interviewers Ask This

Interviewers ask this question to check whether a candidate understands Python method descriptors, automatic argument binding, inheritance, and method selection. It also tests whether the candidate can place behavior correctly based on whether it needs one object, the current class, or neither.

Common interview mistakes

A common mistake is forgetting self in an instance method or cls in a class method. Another mistake is believing that self and cls are Python keywords. They are strong naming conventions, but the binding behavior depends on the method form, not the parameter name. Developers may also use a static method for an alternative constructor, which prevents Python from supplying the subclass automatically. Another mistake is assuming that accessing an instance method through the class supplies an instance. It does not. A caller must provide the instance explicitly. It is also unnecessary to place every helper inside a class. A general helper may be clearer as a module function.

Interview tip

Start with what Python supplies automatically: an instance, a class, or nothing. Then explain that inherited class methods receive the subclass used for access. Finish with one practical use case for each method type.

Interviewer may ask next
What happens when an inherited class method is called through a subclass or a subclass instance?

Python binds the subclass as cls in both cases. A constructor that returns cls(...) therefore creates an instance of that subclass. This matters because one inherited constructor can preserve subclass behavior without being rewritten. The limitation is that the constructor arguments must still be valid for the subclass.

What runtime and design tradeoff exists between a static method and a module function?

Both receive no automatic instance or class argument and usually have similar practical call cost. A static method keeps a closely related helper discoverable through the class namespace, while a module function creates less coupling and is easier to reuse across unrelated classes. Neither form copies instance or class data, so the main tradeoff is code organization rather than performance or memory.

62. How does Python's method resolution order work?Language SpecificMedium

Question Details

Explain attribute lookup across multiple inheritance, the C3 linearization rules at a practical level, how __mro__ exposes the order, and how cooperative super() calls depend on it.

Short Interview Answer (30-60 seconds)

Python uses the method resolution order, or MRO, to decide which class supplies an attribute or method when inheritance is involved. It calculates one consistent order with C3 linearization and exposes it through ClassName.__mro__ or ClassName.mro(). The super function continues with the next class in that order, not always the direct parent. This is why cooperative multiple inheritance requires compatible method signatures and careful super calls.

Detailed Explanation

See the Code while reading this explanation.

Python uses the method resolution order, or MRO, to decide which class supplies an attribute or method when inheritance is involved. For class lookup, Python checks classes in the MRO and uses the first matching definition. Normal instance lookup rules still apply, so an instance attribute or descriptor can affect the result before a class attribute is returned.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

Python calculates the MRO when the class is created by using C3 linearization. In practical terms, the result preserves the parent order written in the class definition, keeps every parent before its own parents, and produces one consistent order without repeating a class. Python raises TypeError if no valid order exists.

You can inspect the order with ClassName.__mro__, which is a tuple, or ClassName.mro(), which returns a list. The super function continues after the current class in that MRO. It does not simply call a fixed parent.

Cooperative inheritance works when each nonterminal override calls super once and compatible methods accept compatible arguments. A final method may intentionally end the chain. The MRO is stored on the class, so Python does not recalculate it for every call. Lookup may inspect several classes, while the stored order uses memory proportional to the number of classes.

How does Python's method resolution order work? diagram
Example

The example uses a diamond inheritance structure. Class D inherits from B and C, while both B and C inherit from A. Python calculates the order D, B, C, A, object. Calling D().process() starts in D. The super call in D continues to B. The super call in B continues to C, not directly to A, because C is next in the MRO. The super call in C then continues to A. Class A intentionally ends this custom method chain because object does not define process. Each custom implementation therefore runs exactly once.

Code
class A:
    def process(self):
        # A is the final custom implementation in this chain.
        # The chain ends here because object has no process method.
        print("A")


class B(A):
    def process(self):
        # Run the behavior owned by B.
        print("B")

        # Continue with the next class in the MRO.
        # For an instance of D, the next class is C.
        super().process()


class C(A):
    def process(self):
        # Run the behavior owned by C.
        print("C")

        # Continue with A, which is next in the MRO.
        super().process()


class D(B, C):
    def process(self):
        # D is the first class searched for this method.
        print("D")

        # Continue with B, which is next in the MRO.
        super().process()


# __mro__ exposes the exact class lookup order as a tuple.
print([class_type.__name__ for class_type in D.__mro__])

# Start the cooperative method chain.
D().process()

# Expected output:
# ['D', 'B', 'C', 'A', 'object']
# D
# B
# C
# A
Where it is used

MRO matters in production code that combines behavior through base classes and mixins. Common examples include framework views, serializers, permission classes, test helpers, logging mixins, and validation mixins. It is especially important when several parent classes define the same method or when each class must add behavior through super. Developers should inspect the MRO when a method runs in an unexpected order. Multiple inheritance should be avoided when the class relationships are difficult to explain, parent methods use incompatible arguments, or the behavior depends on fragile parent ordering.

Why Interviewers Ask This

Interviewers ask this question to check whether a candidate understands attribute lookup in multiple inheritance, C3 linearization, and the real behavior of super. It also tests whether the candidate can inspect class relationships and design cooperative inheritance without skipping or repeating behavior.

Common interview mistakes

A common mistake is assuming that super always calls the direct parent. It continues with the next class after the current class in the MRO. Another mistake is calling a parent method directly, such as A.process(self), because this can skip another class or run a shared ancestor more than once. A class can also break the cooperative chain by omitting super too early, calling it more than once, or using arguments that the next method cannot accept. Developers may also forget that changing the order of parent classes can change the MRO and therefore change runtime behavior.

Interview tip

Begin with the practical rule that Python searches classes in MRO order and uses the first matching definition. Then explain that C3 linearization creates the order, show how __mro__ exposes it, and state that super moves to the next class in that order. Use a small diamond example to prove that super does not always mean direct parent.

Interviewer may ask next
What happens when Python cannot create a consistent MRO?

Python raises TypeError while creating the class. This means the parent relationships and declared parent order contain conflicting requirements that C3 linearization cannot satisfy. It matters because Python rejects an ambiguous class structure instead of selecting an unpredictable lookup order.

What is the main tradeoff of cooperative multiple inheritance?

The main tradeoff is flexibility versus coordination. Cooperative super calls let several classes contribute behavior without naming a fixed parent, but every participating method must follow compatible calling rules and understand the shared MRO. This design is useful for small, focused mixins, but it becomes difficult to maintain when the inheritance graph or method contracts are complex.

63. How does Python's import system find and load modules?Language SpecificMedium

Question Details

Explain the role of sys.modules, import finders and loaders, sys.path, package resolution, caching, and the practical causes of circular-import failures.

Short Interview Answer (30-60 seconds)

Python first checks sys.modules for the fully qualified module name. If the module is already there, Python normally reuses the cached module object. Otherwise, finders try to locate it, usually through sys.meta_path. The standard path finder searches sys.path for a top level module or the parent package path for a child module. A loader then creates the module when needed and executes its code. Python places the module in sys.modules before execution finishes, which prevents repeated loading but can expose a partly initialized module during a circular import.

Detailed Explanation

Python first checks sys.modules, a dictionary that maps fully qualified module names to module objects. If the requested name is present, Python normally reuses that object instead of finding and executing the module again.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

For a new import, Python asks the finders in sys.meta_path for a module specification. The standard path finder searches sys.path for a top level module. For a child module, it searches the parent package path, usually stored in __path__. The specification identifies details such as the loader and module origin.

The loader creates the module when needed and executes its code. Before execution, Python places the module in sys.modules. This early insertion prevents endless repeated loading and lets recursive imports refer to the same object. If execution fails, Python removes the failing module entry, although modules imported successfully as side effects can remain cached.

A circular import fails when one module reads a name from another before that name has been created. This may cause an ImportError or AttributeError involving a partly initialized module. In production, keep package boundaries clear, avoid unnecessary import time work, and move shared definitions into a separate module when two modules depend on each other.

How does Python's import system find and load modules? diagram
Where it is used

This behavior matters when structuring large applications, publishing reusable packages, loading installed libraries, building plugin systems, running test suites, and diagnosing imports that work locally but fail in containers or production. The first successful import can involve finder work, file access, bytecode loading or compilation, and module execution. Later imports normally perform a fast lookup in sys.modules and reuse the same object. Cached modules continue to use memory while they remain in sys.modules or are referenced elsewhere. Imports inside functions can delay optional or expensive dependencies, but they can also make dependencies and failures less visible.

Why Interviewers Ask This

Interviewers ask this question to check whether a candidate understands what Python actually does during an import. It tests knowledge of module caching, search paths, packages, finders, loaders, partial initialization, circular imports, and sound package design in production.

Common interview mistakes

A common mistake is saying that Python simply searches every directory on the computer. Python uses registered finders, and the standard path based finder searches configured locations. Another mistake is saying that every import executes the module again. Successful imports are normally reused from sys.modules. Developers may also assume that importing a package automatically imports every child module, which is not generally true. A local file can shadow a standard library or installed module when it has the same name. Editing sys.path inside application code can hide packaging problems. Moving an import into a function may delay a circular dependency, but it does not always remove the underlying design problem. Deleting a module from sys.modules also does not guarantee that old references to the previous module object disappear.

Interview tip

Explain the process in runtime order. Start with sys.modules. Then describe sys.meta_path, the module specification, the loader, sys.path for top level modules, and the package path for child modules. Finish by explaining that early insertion into sys.modules can expose a partly initialized module during a circular import.

Interviewer may ask next
What happens if a module raises an exception while Python is importing it?

Python propagates the exception and removes the failing module entry that it inserted into sys.modules for that import. This matters because a later import can try to load that module again instead of receiving the failed partial object from the cache. Modules that were imported successfully as side effects are not automatically removed, so they can remain in sys.modules. Code should avoid important irreversible work at import time because some side effects may already have happened before the failure.

What are the tradeoffs of importing a module inside a function?

A function level import delays the import until that function runs. It can reduce initial startup work, support an optional dependency, or delay a dependency that would otherwise participate in a circular import. After the first successful import, later calls normally reuse the object from sys.modules, although each call still performs the import statement and cache lookup. The tradeoff is that dependencies become less visible and import errors occur later. It is useful when delayed loading is intentional, but it should not be used only to hide poor package structure.

64. How do decorators work in Python?Language SpecificMedium

Question Details

Explain that functions are first-class objects, how a decorator receives and replaces a callable, how decorator syntax is evaluated, how to preserve metadata, and how to write a decorator that accepts arguments.

Short Interview Answer (30-60 seconds)

A decorator receives a callable and returns the object that will replace it. Python applies the decorator when it executes the function definition. The at syntax is equivalent to assigning the result of the decorator back to the function name. A wrapper can run logic before or after the original call. In production code, I normally use functools.wraps so tools can still find the original name, documentation, and wrapped function.

Detailed Explanation

See the Code while reading this explanation.

A decorator changes what a function name refers to. Python can do this because functions are first class objects. They can be passed to another function and returned as values.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

When Python executes a decorated function definition, it creates the original function first. It then evaluates the decorator expression, passes the function to the decorator, and assigns the returned object to the original name. Therefore, applying @logger to process is equivalent to process = logger(process). Decoration happens when that definition is executed, which is often during module import, not on every later call.

A common decorator returns a wrapper function. The wrapper runs extra logic, calls the original function, and returns its result. It often accepts *args and **kwargs so it can forward different arguments.

A decorator with arguments needs three levels. The outer function receives configuration, the next function receives the target callable, and the wrapper handles each call.

functools.wraps copies useful metadata and sets __wrapped__. It does not remove the extra call cost. A closure can also keep captured objects alive while the decorated function remains reachable.

How do decorators work in Python? diagram
Example

The example uses a decorator factory named log_calls. The outer function receives the label argument. It returns the real decorator, which receives the target function. The decorator returns a wrapper that accepts any positional and keyword arguments. functools.wraps preserves important metadata and adds a __wrapped__ reference to the original function. Each call prints the configured label and function name, calls the original function once, and returns its result unchanged.

Code
from functools import wraps
from typing import Any, Callable


def log_calls(label: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Create a decorator that logs a label before each function call."""

    # This function receives the target function.
    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:

        # wraps copies useful metadata and sets __wrapped__.
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            # Run the added behavior before the original function.
            print(f"{label}: calling {func.__name__}")

            # Call the original function once and return its result unchanged.
            return func(*args, **kwargs)

        # The returned wrapper replaces the original function name.
        return wrapper

    # Return the real decorator after receiving its configuration.
    return decorator


@log_calls("INFO")
def add(left: int, right: int) -> int:
    """Return the sum of two integers."""
    return left + right


result = add(2, 3)
print(result)
print(add.__name__)
print(add.__doc__)
print(add.__wrapped__(4, 5))
Where it is used

Decorators are useful when the same small behavior must be applied to many callables. Production examples include permission checks, logging, timing, caching, input validation, retry policies, route registration, and test markers. They are a good choice when the added behavior is reusable and clearly connected to the function. They are a poor choice when they hide major control flow, silently change return values, or make failures difficult to trace.

Why Interviewers Ask This

Interviewers ask this question to check whether a candidate understands that functions are objects in Python and can reason about function creation, callable replacement, closures, metadata, and decorator arguments. It also tests whether the candidate can use decorators without hiding important behavior or creating difficult debugging and typing problems.

Common interview mistakes

Common mistakes include calling the target function while creating the decorator instead of inside the wrapper, forgetting to return the wrapper, and forgetting to return the original result. A wrapper with fixed parameters may fail for functions with different call signatures, so forwarding *args and **kwargs is common. Omitting functools.wraps causes misleading names and documentation. Another mistake is assuming wraps gives the wrapper the exact runtime signature of the original function. It preserves metadata and sets __wrapped__, but the wrapper itself still uses its declared parameters. Decorators can also be applied in the wrong order because stacked decorators are applied from the bottom upward.

Interview tip

Begin with the replacement rule. Say that @decorator above a function is equivalent to assigning decorator(function) back to the same name. Then explain the wrapper, when decoration occurs, why functools.wraps matters, and why decorator arguments require one extra function level.

Interviewer may ask next
What happens when several decorators are stacked on one function?

Python applies stacked decorators from the bottom upward. For @outer above @inner, the result is function = outer(inner(function)). Calls then normally enter the outer wrapper first and continue inward. This matters because decorator order can change validation, caching, logging, exceptions, and returned values.

What performance and memory costs can a decorator add?

A wrapper adds at least one extra Python function call for each decorated call, plus the cost of its own work. Several stacked wrappers add several call layers. A closure also stores references to captured values such as the original function and configuration. Those objects can remain alive while the decorated callable remains reachable. The cost is often small, but it can matter for very frequently called functions or when a closure captures large objects.

65. How do closures work in Python?Language SpecificMedium

Question Details

Explain how an inner function retains access to names from an enclosing scope, how late binding affects captured loop variables, and when nonlocal is needed to rebind captured state.

Short Interview Answer (30-60 seconds)

A closure is an inner function that keeps access to names from an enclosing function even after the enclosing function has returned. Python keeps references to those captured names rather than copying their current values. This causes late binding, so functions created in a loop may all read the final loop value. I can save each current value with a default argument, and I use nonlocal only when the inner function must rebind a captured name.

Detailed Explanation

See the Code while reading this explanation.

A closure is created when an inner function refers to a name from an enclosing function scope and the inner function is returned or stored for later use. Python keeps the captured name in a closure cell, so the inner function can still access it after the outer call has finished.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

The captured object is not copied. The closure keeps a reference to it. Python also uses late binding for captured names. The value is normally read when the inner function runs, not when its function object is created. This matters in loops because several functions may share the same loop variable and later return its final value. A common fix is a default argument because its value is evaluated each time the def statement runs.

Reading a captured name needs no keyword. Mutating a captured mutable object also needs no nonlocal statement. However, assigning a different object to a captured name is rebinding, so nonlocal is required. It targets the nearest enclosing function scope and cannot target a global name.

Closures are useful for decorators, callbacks, factories, and small private state. Creating or calling one is normally constant time, apart from the work inside it. Captured references use memory and may keep large objects alive. For complex state, a class is often clearer.

How do closures work in Python? diagram
Example

The code demonstrates the three behaviors required by the question. make_multiplier captures factor and reads it after make_multiplier has returned. build_functions stores the current loop value in a default argument, so each returned function has its own saved value instead of reading one shared loop variable later. make_counter uses nonlocal because increment assigns a new integer object to count. The examples print 12, then the list 0, 1, 2, and then the counter values 1 and 2.

Code
def make_multiplier(factor):
    # This inner function captures factor from the enclosing scope.
    def multiply(number):
        # factor remains available after make_multiplier returns.
        return number * factor

    # Return the function object for later use.
    return multiply


def build_functions():
    functions = []

    for value in range(3):
        # This default value is evaluated when this def statement runs.
        # Each loop iteration therefore stores its own current value.
        def read_value(saved_value=value):
            return saved_value

        functions.append(read_value)

    return functions


def make_counter():
    count = 0

    def increment():
        # Assignment would otherwise make count local to increment.
        # nonlocal allows rebinding in the nearest enclosing function scope.
        nonlocal count
        count += 1
        return count

    return increment


# The closure keeps factor equal to 3.
triple = make_multiplier(3)
print(triple(4))

# Each function returns the value saved during its loop iteration.
readers = build_functions()
print([reader() for reader in readers])

# The closure keeps and updates count between calls.
counter = make_counter()
print(counter())
print(counter())
Where it is used

Closures are used in decorator factories, callback creation, event handlers, configured functions, and small stateful utilities. A retry decorator can capture a retry limit. A validation function can capture configuration. A counter can keep private state between calls. Closures work best when the captured state is small and the behavior has only a few operations.

Why Interviewers Ask This

Interviewers ask this question to test whether a candidate understands nested scopes, function objects, closure cells, late binding, and the nonlocal statement. It also shows whether the candidate can recognize subtle bugs in callbacks, decorators, and functions created inside loops.

Common interview mistakes

A common mistake is assuming that a closure stores an independent frozen copy of every captured value. It normally stores access to a closure cell, so late binding can make loop functions share the final loop value. Another mistake is assigning to a captured name without nonlocal. Python then treats that name as local to the inner function, which can cause UnboundLocalError if it is read before assignment. Developers also sometimes use nonlocal when only mutating a captured list or dictionary, even though rebinding is not occurring. A closure can also keep captured objects alive longer than expected, which matters when those objects use significant memory or hold external resources.

Interview tip

Begin with the main rule that a closure keeps access to an enclosing scope. Then explain that Python captures names through closure cells and uses late binding. Use a loop example to show the problem, explain the default argument fix, and finish by stating that nonlocal is needed for rebinding but not for reading or mutating a captured object.

Interviewer may ask next
Why do functions created in a loop often return the same final value?

They often return the same final value because Python uses late binding for the captured loop name. The functions share access to the same closure cell, and they read that cell when they are called. After the loop ends, the cell contains the final loop value. A default argument fixes this behavior by evaluating and storing the current value each time the def statement runs.

Do you need nonlocal to change a captured list?

No, nonlocal is not needed when the inner function only mutates the existing captured list, such as by calling append. The captured name still refers to the same list object. nonlocal is required only when the function assigns a different object to that name. This distinction matters because mutation changes an object, while rebinding changes which object the name refers to.

66. How do context managers implement the with statement?Language SpecificMedium

Question Details

Explain the __enter__ and __exit__ protocol, how exception information is passed to __exit__, how suppression works, and how contextlib.contextmanager provides an alternative implementation style.

Short Interview Answer (30-60 seconds)

A context manager implements the with statement through __enter__ and __exit__. Python calls __enter__ before the block and assigns its return value to the name after as. If __enter__ succeeds, Python calls __exit__ when control leaves the block. When an exception occurs, __exit__ receives its type, value, and traceback. Returning True suppresses that exception. Returning False or None lets it continue. The contextlib.contextmanager decorator provides the same protocol through a generator that performs setup before yield and cleanup after yield.

Detailed Explanation

See the Code while reading this explanation.

The practical purpose of a context manager is to prepare a resource and release it reliably. Python first evaluates the expression after with and obtains a context manager. It calls __enter__, and the returned value becomes the value after as. If __enter__ raises an exception, the block never starts and __exit__ is not called.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

After __enter__ succeeds, Python runs the block. When control leaves the block normally, Python calls __exit__ with three None values. If the block raises an exception, Python passes the exception type, exception object, and traceback. Returning True tells Python that the exception was handled. Returning False or None allows it to continue.

The contextlib.contextmanager decorator offers a generator based style. Code before yield performs setup. The yielded value becomes the as value. Code after yield performs cleanup. A try and finally block is normally used so cleanup still runs when the block fails.

Context managers are useful for files, locks, transactions, temporary resources, and temporary state changes. Their time and memory cost is normally small. Python creates a manager object or generator object, but it does not automatically copy the managed resource.

How do context managers implement the with statement? diagram
Example

The code demonstrates both supported implementation styles. ResourceManager uses __enter__ for setup and returns the resource used after as. Its __exit__ method always closes the resource after __enter__ succeeds. It prints any exception information and returns False, so the original exception continues. The managed_resource function uses contextlib.contextmanager. It creates the resource before yield, yields the value used after as, and closes it in a finally block. The example catches the propagated ValueError outside the with statement so the program can continue and show that cleanup happened.

Code
from contextlib import contextmanager


class ResourceManager:
    def __enter__(self):
        # Create the resource before the with block starts.
        print("Class manager: opening resource")
        self.resource = {"status": "open"}

        # This returned value is assigned after as.
        return self.resource

    def __exit__(self, exception_type, exception_value, traceback):
        # This method runs when control leaves the with block.
        self.resource["status"] = "closed"
        print("Class manager: closing resource")

        # Python provides exception details when the block fails.
        if exception_type is not None:
            print(f"Class manager received: {exception_type.__name__}: {exception_value}")

        # False means that an active exception must continue.
        return False


@contextmanager
def managed_resource():
    # Code before yield performs setup.
    print("Generator manager: opening resource")
    resource = {"status": "open"}

    try:
        # The yielded value is assigned after as.
        yield resource
    finally:
        # The finally block guarantees cleanup after yield.
        resource["status"] = "closed"
        print("Generator manager: closing resource")


def main():
    # Normal completion calls __exit__ with three None values.
    with ResourceManager() as class_resource:
        print(f"Inside class manager: {class_resource}")

    print(f"After class manager: {class_resource}")

    try:
        # The raised error is sent back into the generator at yield.
        with managed_resource() as generator_resource:
            print(f"Inside generator manager: {generator_resource}")
            raise ValueError("example failure")
    except ValueError as error:
        # The generator manager did not suppress the exception.
        print(f"Caller received: {error}")

    print(f"After generator manager: {generator_resource}")


if __name__ == "__main__":
    main()
Where it is used

Context managers are used when code must always release or restore something after use. Common production examples include closing files, releasing thread locks, committing or rolling back database transactions, closing network connections, managing temporary files, changing a working directory for a limited block, and measuring execution time. They make ownership and cleanup visible at the point of use. They should not suppress broad exceptions unless they can fully handle those failures and leave the application in a valid state.

Why Interviewers Ask This

Interviewers ask this question to check whether the candidate understands what Python does behind the with statement. It tests knowledge of the context manager protocol, cleanup guarantees, exception flow, exception suppression, and the contextlib module. It also tests whether the candidate knows when a class or a generator based context manager is clearer in production code.

Common interview mistakes

A common mistake is to say that __exit__ always runs. It runs only after __enter__ completes successfully. Another mistake is returning True without realizing that it suppresses the active exception. Developers may also assume that the value after as must be the context manager itself, but __enter__ can return another object. With contextlib.contextmanager, the generator must yield exactly once. Cleanup should normally be placed in a finally block. Catching an exception around yield and then ending normally can suppress that exception, so the code must raise it again when suppression is not intended. A context manager also does not automatically make a resource safe for concurrent use.

Interview tip

Start with the __enter__ and __exit__ protocol. Explain what value is assigned after as. Then name the three exception values passed to __exit__ and state that True suppresses the exception. Mention that __exit__ is not called when __enter__ fails. Finish by explaining that contextlib.contextmanager uses setup before yield and cleanup after yield.

Interviewer may ask next
What happens if __enter__ raises an exception?

The with block does not start, and Python does not call that context manager's __exit__ method. This matters because any partial setup completed inside __enter__ must be cleaned up by __enter__ itself before it raises. A safer design may perform risky setup before changing persistent state or use a local try and except block to undo partial work.

When should you choose contextlib.contextmanager instead of a class?

Choose contextlib.contextmanager when setup and cleanup are small and fit clearly around one yield. It reduces boilerplate and is often easier to read. Choose a class when the manager needs several methods, reusable state, inheritance, or more complex behavior. Both styles have small object creation overhead, and the clearer design is usually more important than that minor cost.

67. How do type hints work at runtime?Language SpecificMedium

Question Details

Explain that annotations normally do not enforce types by themselves, how __annotations__ stores metadata, how static type checkers use it, and how generics, unions, and forward references are represented.

Short Interview Answer (30-60 seconds)

Type hints normally do not enforce types at runtime. Python keeps annotations as metadata, usually available through __annotations__, while static type checkers use them before execution. Runtime tools can inspect and act on the metadata, but they must perform their own validation. Generic types, unions, and forward references are represented as annotation values whose exact runtime form can depend on the Python version and annotation settings.

Detailed Explanation

See the Code while reading this explanation.

Type hints normally do not reject a value at runtime. Python still follows dynamic typing, so a function can receive a value that does not match its annotation unless some other code checks it.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

Annotations are metadata associated with functions, classes, and modules. They are commonly available through __annotations__. In Python 3.14, annotations are evaluated lazily by default, so reading __annotations__ may evaluate annotation expressions and can raise an exception. In earlier versions, annotations were usually evaluated when the definition ran unless postponed annotation behavior was enabled.

Static type checkers read type hints without changing normal program execution. Editors also use them for completion and warnings. Runtime tools can use typing.get_type_hints to resolve annotations, including many forward references. Resolution may execute annotation code, allocate a result dictionary, and fail when a required name is missing.

A generic such as list[str] is represented as a generic alias. A union such as int | str is represented as a union type. A forward reference may remain a string or another delayed form until resolved.

Type hints improve clarity and tooling, but they do not replace validation of API requests, files, database values, or other external input.

How do type hints work at runtime? diagram
Example

The example defines a User class and a function with three annotation forms. list[User] is a generic alias. int | None is a union. The return annotation is str. The first call shows normal use. The second call proves that Python does not automatically enforce the limit annotation. The function completes because its own runtime logic accepts the supplied string value. The example then reads __annotations__ and uses get_type_hints to obtain resolved type information. The exact printed representation can vary between supported Python versions, but the runtime behavior and conclusion remain the same.

Code
from typing import get_type_hints


class User:
    """A small class used in the annotation example."""

    def __init__(self, name: str) -> None:
        # This annotation describes the expected value.
        # Python does not enforce it by itself.
        self.name = name


def describe_users(users: list[User], limit: int | None = None) -> str:
    """Return names from the supplied users."""

    # This comparison works with both integers and the string used below.
    # It lets the example prove that Python accepted the wrong runtime type.
    if limit is None:
        selected_users = users
    elif limit == 1:
        selected_users = users[:1]
    else:
        selected_users = users

    return ", ".join(user.name for user in selected_users)


users = [User("Asha"), User("Luis")]

# This call matches the annotations.
print(describe_users(users, 1))

# This call does not match the limit annotation.
# Python still allows it because type hints are not automatic checks.
print(describe_users(users, "one"))

# Access the annotation metadata associated with the function.
print(describe_users.__annotations__)

# Resolve the annotations into runtime type information.
print(get_type_hints(describe_users))
Where it is used

Type hints are used in application services, APIs, libraries, data models, tests, and shared interfaces. Static type checkers use them to find likely mistakes before deployment. Editors use them for suggestions and warnings. Frameworks and validation libraries may inspect annotations at runtime to build schemas, connect dependencies, serialize data, or validate input. External values still require runtime validation. Annotation inspection should not be repeated on every request when the resolved result can be safely reused, because resolution performs work and creates result objects.

Why Interviewers Ask This

Interviewers ask this to check whether the candidate understands the difference between type information and runtime enforcement. It also tests knowledge of annotation storage, static analysis, runtime inspection, forward reference resolution, and safe production use.

Common interview mistakes

A common mistake is assuming that Python automatically rejects arguments that do not match annotations. Another mistake is treating __annotations__ as validated data rather than metadata. Developers may also assume annotations always have the same runtime representation in every Python version. Lazy evaluation, postponed annotation behavior, and forward references can change what is stored or when it is evaluated. Another mistake is calling get_type_hints on untrusted annotations without considering that annotation evaluation can execute code. Repeatedly resolving the same annotations in a busy request path can also add avoidable processing and allocation.

Interview tip

Begin with the conclusion that type hints do not normally enforce types. Then explain static checking, __annotations__, and runtime inspection with get_type_hints. Mention that Python 3.14 evaluates annotations lazily by default. Finish by explaining how a generic, a union, and a forward reference appear at runtime.

Interviewer may ask next
What happens if a forward reference cannot be resolved?

The resolution can fail when the referenced name is not available in the required namespace. The annotation may still exist in a delayed or string form, but get_type_hints using value resolution can raise NameError or another evaluation exception. This matters when types are imported only for static checking or are defined in a different scope. Production code should provide the correct namespaces or avoid resolving the annotation until the required names are available.

What are the production costs and risks of inspecting type hints at runtime?

Runtime inspection adds processing and memory work because Python may evaluate annotation expressions, resolve names, and create a dictionary of results. get_type_hints may also execute code contained in annotations, so it should not be used carelessly with untrusted definitions. The main tradeoff is that runtime inspection enables schema creation, dependency handling, and validation tools, but repeated resolution in a busy path can waste resources. A production system can resolve trusted annotations once and safely reuse the result when appropriate.

68. How do dataclasses generate class behavior?Language SpecificMedium

Question Details

Explain how @dataclass derives methods such as __init__, __repr__, and __eq__, how field defaults and default_factory work, and how frozen, order, slots, and post-initialization options change behavior.

Short Interview Answer (30-60 seconds)

A dataclass inspects the annotated fields in a class and can generate methods such as __init__, __repr__, and __eq__. This removes repeated class code while keeping the fields clear. I use default_factory for mutable defaults, __post_init__ for validation or derived values, frozen to block normal field assignment, order for field based comparisons, and slots when many small objects need lower memory use.

Detailed Explanation

See the Code while reading this explanation.

The practical benefit of @dataclass is that it generates common class behavior from annotated fields. By default, it creates __init__, __repr__, and __eq__. The generated __init__ accepts field values and stores them on the object. The generated __repr__ shows the class name and fields. The generated __eq__ compares objects of the same class using fields marked for comparison.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

A normal default is reused as the declared default value. Mutable defaults such as lists must use default_factory. The factory runs for each new object, so objects do not accidentally share one list.

The frozen option blocks normal assignment and deletion of fields after initialization. It does not make mutable values inside the object immutable. The order option generates comparison methods from fields in declaration order and requires equality support. The slots option creates slotted instances without the usual instance dictionary, which can reduce memory use and prevents undeclared attributes.

After the generated __init__ finishes, __post_init__ runs. It is useful for validation and derived fields. In a frozen dataclass, object.__setattr__ is needed to set a derived field during this step. Dataclasses work best for clear data models. A regular class is often better when construction rules or behavior are highly complex.

How do dataclasses generate class behavior? diagram
Example

The example defines an ordered, frozen, and slotted Product dataclass. The name and price fields become parameters in the generated __init__. The tags field uses default_factory, so each Product receives a separate list. It is excluded from equality, ordering, and hashing because compare is false and hash follows that setting by default. The display_name field has init set to false, so callers cannot pass it to the constructor. After initialization, __post_init__ validates the price and calculates display_name. Because the object is frozen, object.__setattr__ is used during post initialization. The generated __repr__ displays the fields, __eq__ compares the selected fields, and the generated ordering methods compare name first and price second.

Code
from dataclasses import FrozenInstanceError, dataclass, field


@dataclass(frozen=True, order=True, slots=True)
class Product:
    # These fields become parameters in the generated __init__ method.
    name: str
    price: float

    # default_factory creates a new list for every Product object.
    # compare=False excludes this field from equality and ordering.
    tags: list[str] = field(default_factory=list, compare=False)

    # This field is calculated after the generated initialization finishes.
    # Callers cannot pass it to __init__ because init is false.
    display_name: str = field(init=False, compare=False)

    def __post_init__(self) -> None:
        # Validate the values received by the generated __init__ method.
        if self.price < 0:
            raise ValueError("price cannot be negative")

        # Normal assignment is blocked because the dataclass is frozen.
        # object.__setattr__ allows this derived value to be set here.
        object.__setattr__(
            self,
            "display_name",
            f"{self.name}: ${self.price:.2f}",
        )


first = Product("Keyboard", 49.99, ["hardware"])
second = Product("Mouse", 29.99)
third = Product("Keyboard", 49.99, ["sale"])

# The generated __repr__ displays the dataclass fields.
print(first)

# The generated __eq__ compares name and price.
# tags and display_name are ignored because compare is false.
print(first == third)

# The generated ordering methods compare name first and price second.
print(sorted([first, second]))

# Each object receives a different list from default_factory.
print(first.tags is second.tags)

# __post_init__ created this derived value.
print(first.display_name)

# Frozen objects reject normal field assignment.
try:
    first.price = 10.0
except FrozenInstanceError as error:
    print(type(error).__name__)
Where it is used

Dataclasses are used for configuration values, API request data, service results, domain records, test fixtures, parsed records, and internal messages. Frozen dataclasses are useful when field references should not change after creation. Ordered dataclasses are useful when declaration order matches the required sorting rule. Slotted dataclasses are useful when an application creates many small objects and memory use matters.

Why Interviewers Ask This

Interviewers ask this question to check whether a candidate understands how Python creates class methods from declared fields. It also tests judgment about safe defaults, equality, ordering, controlled immutability, memory use, validation, and maintainable data models.

Common interview mistakes

A common mistake is using a list or dictionary as a direct default instead of using default_factory. Another mistake is assuming frozen makes every nested value immutable. A frozen dataclass can still contain a list whose contents can change. Developers may enable order without checking whether field declaration order matches the required comparison rule. Other mistakes include placing a required field after a field with a default, expecting __post_init__ to replace all complex construction logic, forgetting that equality requires the same class, and assuming slots supports undeclared instance attributes.

Interview tip

Start by saying that @dataclass generates standard methods from annotated fields. Then explain default_factory for safe mutable defaults. Finish by stating how frozen, order, slots, and __post_init__ change the generated behavior.

Interviewer may ask next
Does frozen make every value inside a dataclass immutable?

No. Frozen blocks normal assignment and deletion of dataclass fields, but it does not make nested mutable values immutable. A frozen dataclass can contain a list whose contents can still change. This matters because callers may incorrectly assume the whole object cannot be modified. Use immutable values such as tuples when nested values must also remain unchanged.

When should you use slots in a dataclass?

Use slots when the allowed attributes are known and the program creates many small objects. Slots removes the usual instance dictionary, which can reduce memory use and prevent accidental undeclared attributes. Attribute access may also be slightly faster, but that should not be assumed without measurement. The main tradeoff is reduced flexibility, and inheritance, weak references, inspection, or serialization tools may need additional care.

69. What is the GIL, and how does it affect multithreaded Python programs?Language SpecificHard

Question Details

Explain the Global Interpreter Lock in CPython, how it affects CPU-bound and I/O-bound threads, when threads can still help, and when multiprocessing or asyncio is a better choice.

Short Interview Answer (30-60 seconds)

The GIL is a lock in the normal CPython runtime. It allows only one thread at a time to execute Python bytecode in one process. Because of this, adding threads usually does not speed up CPU heavy Python code. Threads can still help with network, file, and database work because the runtime releases the GIL while a thread waits for many blocking operations. I use multiprocessing for heavy CPU work and asyncio for many cooperative input and output tasks. Python also has an optional free threaded build, but library support must be checked.

Detailed Explanation

See the Code while reading this explanation.

The practical point is that Python threads are useful for waiting work, but they usually do not make CPU heavy Python code run in parallel on the normal CPython build.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

The Global Interpreter Lock, usually called the GIL, is a runtime lock in CPython. A thread must hold this lock before it can execute Python bytecode or safely work with Python objects. This design protects important interpreter state, including memory management details such as reference counts.

A process can contain many threads. The operating system may schedule those threads at the same time. However, in the normal CPython build, only one thread in that process can execute Python bytecode at a given moment. CPython switches the GIL between threads, so the program can still make progress concurrently. This is concurrency, but it is not full parallel execution of Python bytecode.

For CPU heavy work, threads often give little speed improvement. Examples include large pure Python loops, image calculations written in Python, or number processing that keeps the interpreter busy. The threads compete for the same GIL. Thread switching also adds overhead. For this work, separate processes are often better because each process has its own Python interpreter and its own GIL. ProcessPoolExecutor and multiprocessing are common choices.

Threads are still useful for input and output work. CPython releases the GIL around many blocking operations. Examples include waiting for a network response, reading a file, or waiting for a database call. While one thread waits, another thread can run. This makes thread pools useful for existing blocking libraries and for applications that need moderate input and output concurrency.

Asyncio is often better when one application must manage many network connections. It uses an event loop and cooperative tasks. A task gives control back when it reaches await. This can support many connections with fewer operating system threads. The downside is that the libraries must support async operations, and blocking code must not run directly on the event loop.

Some native libraries release the GIL while performing heavy work outside Python. For example, selected operations in scientific or compression libraries may execute native code without holding the GIL. In that case, threads may use more than one CPU core. This depends on the exact library and operation, so it should be measured rather than assumed.

Python now also supports an optional free threaded CPython build. It can run Python threads in parallel without the GIL. It is supported but is not the normal default build. Some extension modules may not support it and can cause the GIL to become enabled again. Shared mutable data still needs locks or another synchronization method. Removing the GIL does not remove race conditions.

My production choice is based on the workload. I use threads for blocking input and output with synchronous libraries. I use asyncio for many cooperative input and output tasks. I use processes for CPU heavy Python work. I consider the free threaded build only after checking package support, thread safety, and measured performance.

What is the GIL, and how does it affect multithreaded Python programs? diagram
Key Insight / Why This Solution Works

First, identify whether the workload spends most of its time computing or waiting. Second, check whether the code runs mainly as Python bytecode or inside a native library that releases the GIL. Third, choose threads for blocking input and output when synchronous libraries are already used. Choose asyncio when many tasks can cooperate through await. Choose multiprocessing or ProcessPoolExecutor for heavy Python computation. Fourth, protect shared mutable data because the GIL does not make a complete operation automatically safe. Finally, measure the real workload. Confirm throughput, latency, CPU use, memory use, and process overhead before deciding that one concurrency model is better.

Example

This example runs the same CPU task with a process pool and several waiting tasks with a thread pool. The CPU function performs pure Python arithmetic, so separate processes allow work to use different interpreter processes. The input and output example uses sleep to represent waiting for a network, file, or database operation. Threads help there because one thread can run while another waits. The example does not claim that sleep is real production input and output. It only demonstrates the scheduling difference. The main function protects process creation and allows the example to run correctly on platforms that start new processes by importing the module.

Code
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import time


def cpu_task(limit: int) -> int:
    total = 0
    for value in range(limit):
        total += value * value
    return total


def io_task(name: str, delay: float) -> str:
    time.sleep(delay)
    return f"{name} completed"


def main() -> None:
    with ProcessPoolExecutor(max_workers=4) as process_pool:
        cpu_results = list(process_pool.map(cpu_task, [2_000_000] * 4))

    with ThreadPoolExecutor(max_workers=4) as thread_pool:
        futures = [thread_pool.submit(io_task, f"request {index}", 1.0) for index in range(4)]
        io_results = [future.result() for future in futures]

    print(cpu_results)
    print(io_results)


if __name__ == "__main__":
    main()
Where it is used

Threads are useful in web crawlers, file transfer tools, database clients, and services that call several blocking APIs. Asyncio is common in network servers, websocket services, chat systems, and clients that manage many connections. Processes are useful for data transformation, report generation, image processing, and other CPU heavy Python work. Thread pools also help when an application uses a synchronous library inside a larger service. A free threaded build may help programs designed for safe shared memory parallelism, but teams should first confirm that their libraries and extension modules support it.

Why Interviewers Ask This

Interviewers ask this question to check whether the candidate understands CPython runtime behavior and can choose the right concurrency model. A strong answer separates concurrency from parallel execution. It also explains why threads still help with waiting work, why processes help with heavy Python computation, and when asyncio is simpler. In 2026, a strong candidate should also know that CPython offers an optional free threaded build, while the normal build still commonly uses the GIL.

Common interview mistakes

A common mistake is saying that the GIL makes Python completely single threaded. Python can run many threads, but the normal CPython build allows only one thread at a time to execute Python bytecode in one process. Another mistake is saying that the GIL makes shared data safe. Several bytecode steps can still interleave, so locks may be required. Candidates also assume that threads never help CPU work. Native code may release the GIL, so the result depends on the library. Another mistake is using asyncio for blocking code without moving that work away from the event loop. Finally, do not claim that every Python installation is free threaded. The free threaded build is optional.

Interview tip

Start by saying that the answer applies to the normal CPython build. Then separate CPU heavy work from input and output work. Explain threads, asyncio, and processes as workload choices. Mention the optional free threaded build near the end. Also state that the GIL does not replace application locks. This shows both current runtime knowledge and practical engineering judgment.

Interviewer may ask next
Does the GIL make operations on shared Python data thread safe?

No. The GIL protects CPython interpreter state, but it does not make every application operation atomic or logically safe. A statement can involve several bytecode steps. CPython may switch threads between those steps. For example, reading a value, calculating a new value, and writing it back can interleave with another thread. This can create lost updates or inconsistent state. Some individual built in operations appear atomic in the current CPython implementation, but application code should not depend on undocumented implementation details. I use Lock, RLock, Queue, immutable data, or message passing when several threads share mutable state. On a free threaded build, explicit synchronization becomes even more important because Python code can run in parallel. The exact change is not to remove threading. It is to protect the shared state and keep the protected section small. The tradeoff is extra coordination and possible lock contention.

How would your choice change when using the free threaded CPython build?

I would first verify that the running interpreter has the GIL disabled and that every important extension module supports free threading. If those checks pass, CPU heavy Python threads may run in parallel across cores. I could then compare a thread pool with a process pool for the real workload. Threads may reduce process startup, memory, and serialization costs because they share one address space. However, shared mutable state now needs careful synchronization, and some libraries may enable the GIL again when imported. The exact change is that threading becomes a possible choice for CPU parallelism, not only for waiting work. I would still keep asyncio for workloads built around many cooperative input and output operations. I would measure throughput, latency, memory, and lock contention before changing production architecture. The main tradeoff is easier data sharing against greater risk of races and package compatibility problems.

70. What are descriptors in Python, and how does property work internally?Language SpecificHard

Question Details

Explain the descriptor protocol, __get__, __set__, and __delete__, the difference between data and non-data descriptors, and how Python uses descriptors to implement property and bound methods.

Short Interview Answer (30-60 seconds)

Descriptors are objects stored on a class that can control how an attribute is read, assigned, or deleted. They use __get__, __set__, and __delete__. A descriptor that defines __set__ or __delete__ is a data descriptor. A descriptor that defines only __get__ is a non data descriptor. Property is a data descriptor because the property type provides __get__, __set__, and __delete__. Its methods call the getter, setter, or deleter supplied by the class, and raise AttributeError when the required function is missing. Python functions also act as non data descriptors, which is how an instance method becomes bound to an instance.

Detailed Explanation

See the Code while reading this explanation.

The practical purpose of a descriptor is to control attribute access while callers continue to use normal dot syntax. A descriptor is an object stored on a class. Python may call its __get__ method when an attribute is read, __set__ when it is assigned, and __delete__ when it is deleted.

Useful Questions to Ask the Interviewer
  1. Should I focus on Python language behavior, or also explain the runtime and standard library?
  2. Which Python version and execution environment should I assume?
  3. Would you like a small code example together with production tradeoffs and edge cases?

A data descriptor defines __set__ or __delete__. It takes priority over a value with the same name in the instance dictionary. A non data descriptor defines __get__ without defining __set__ or __delete__. An instance value can normally override it.

Property uses this protocol. A property object is stored on the class. Its __get__ calls the getter. Its __set__ calls the setter. Its __delete__ calls the deleter. The property type still has these descriptor methods when a setter or deleter was not supplied, so the missing operation raises AttributeError.

Functions stored on a class are non data descriptors. Their __get__ creates bound methods that automatically pass the instance as the first argument.

Descriptor access is normally constant time for dictionary backed storage, but it adds lookup and function call work. The descriptor object is shared by the class, while each instance usually stores its own managed value.

What are descriptors in Python, and how does property work internally? diagram
Example

The example uses one custom data descriptor and one property. PositiveNumber is stored on the Product class. __set_name__ records a private storage name when the class is created. __get__ returns the descriptor during class access and returns the saved instance value during instance access. __set__ validates each new value before storing it in the instance dictionary. The label property validates one specific attribute. Reading label calls its getter, while assigning label calls its setter. The describe function also demonstrates descriptor behavior because Python turns it into a bound method when it is accessed through a Product instance.

Code
class PositiveNumber:
    """Manage a positive numeric value for each instance."""

    def __set_name__(self, owner, name):
        # Save the name used for this descriptor on the owner class.
        # The actual value will be stored separately in each instance.
        self.storage_name = "_" + name

    def __get__(self, instance, owner=None):
        # Access through the class returns the descriptor itself.
        if instance is None:
            return self

        # Access through an instance returns that instance's stored value.
        return instance.__dict__[self.storage_name]

    def __set__(self, instance, value):
        # Reject booleans because bool is a subclass of int in Python.
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError("The value must be a number")

        # Accept only values greater than zero.
        if value <= 0:
            raise ValueError("The value must be greater than zero")

        # Store the value in the instance, not in the shared descriptor.
        instance.__dict__[self.storage_name] = value


class Product:
    # This object defines __set__, so it is a data descriptor.
    price = PositiveNumber()

    def __init__(self, name, price):
        # This assignment calls the property setter.
        self.label = name

        # This assignment calls PositiveNumber.__set__.
        self.price = price

    @property
    def label(self):
        # property.__get__ calls this getter.
        return self._label

    @label.setter
    def label(self, value):
        # property.__set__ calls this setter.
        if not isinstance(value, str) or not value.strip():
            raise ValueError("The label must be a non empty string")

        # Use a different backing name to avoid calling the setter again.
        self._label = value.strip()

    def describe(self):
        # A function stored on a class is a non data descriptor.
        # Access through an instance creates a bound method.
        return f"{self.label}: ${self.price:.2f}"


product = Product("Keyboard", 75)

print(product.label)
print(product.price)
print(product.describe())

product.label = "Mechanical Keyboard"
product.price = 90

print(product.describe())

try:
    product.price = -10
except ValueError as error:
    print(error)
Where it is used

Property is useful when one class attribute needs validation, conversion, controlled updates, or a computed value. Custom descriptors are useful when the same rule must be reused across many attributes or classes. Python also uses descriptors for bound methods, classmethod, staticmethod, slots, and many framework managed fields. In production, descriptor logic should remain small and predictable. Slow database calls, network calls, or surprising state changes should usually not happen during ordinary attribute access. A descriptor stored on the class is shared by all instances, so mutable state placed inside the descriptor may also be shared. Per instance values should normally be stored in the instance dictionary, in a slot, or in another storage structure designed for instance specific data.

Why Interviewers Ask This

Interviewers ask this question to test whether a candidate understands Python attribute access below the surface. It checks knowledge of the descriptor protocol, attribute lookup order, property behavior, method binding, reusable validation, and the practical cost of placing logic behind normal attribute access.

Common interview mistakes

A common mistake is saying that every descriptor must define all three protocol methods. A descriptor may define only __get__. Another mistake is calling every descriptor with __get__ a data descriptor. The defining rule is whether its type provides __set__ or __delete__. Candidates may also say that a read only property is a non data descriptor. It is still a data descriptor because the property type provides __set__ and __delete__, even when those operations raise AttributeError. Another mistake is reading or assigning the public property name inside its own getter or setter, which causes endless recursion. Developers may also forget to return the descriptor itself when __get__ receives instance as None. Finally, storing instance values directly on a shared descriptor can accidentally share state across every instance.

Interview tip

Start with the practical result: descriptors control attribute access behind normal dot syntax. Then name __get__, __set__, and __delete__. Explain the lookup difference between data and non data descriptors. Finish by connecting property to data descriptors and bound methods to function descriptors.

Interviewer may ask next
What happens if an instance dictionary contains the same name as a descriptor?

A data descriptor still takes priority over the instance dictionary. A non data descriptor does not, so an instance value with the same name can override it. This matters because a property remains in control even if the instance dictionary contains that public name, while a normal method can be shadowed by assigning an attribute with the method name on that instance.

When should you use a custom descriptor instead of property?

Use a custom descriptor when the same attribute behavior must be reused across several fields or classes. Use property when the logic belongs to one attribute on one class. A descriptor reduces repeated validation or storage code, but it adds indirection and can make attribute behavior harder to trace. It also creates one shared descriptor object, so instance specific state must be stored separately and not as mutable state on the descriptor itself.

More questions load as you scroll

Disclaimer: This interview guide is for educational and informational purposes only. It is designed to help readers prepare, but it does not guarantee any interview result, hiring decision, offer, or outcome. Interview questions, hiring criteria, and preferred answers can vary by employer, interviewer, industry, location, and time. The examples and explanations reflect the authors' research and judgment, are provided without warranties of any kind, and should not be treated as the only correct approach. Diagrams are simplified illustrations intended to highlight the main components and their interactions; actual systems and implementations may be more complex. Alternative approaches may be equally valid or better suited to a particular question, context, or interviewer. To the fullest extent permitted by applicable law, the author, contributors, and publisher are not liable for decisions made, actions taken, or losses incurred based on this guide.