skip to content

Reflection Over Objects

Asking an object about itself while the program runs: what a callable's parameters are, how to reach an attribute named by a string, and what a function carries besides its body.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

12

What does getattr(obj, 'x', None) give you that plain obj.x does not?

level: juniorimportance: must knowfreq 70%

answer

  1. The name arrives as data
  2. Three arguments, not two
  3. The third one swallows something
  4. Write and delete halves exist too
  5. Only AttributeError is suppressed

basics

~20 s

getattr takes the attribute name as a runtime string, and its optional third argument is returned instead of raising AttributeError when the name is absent. Dotted syntax needs the name written in the source and always raises.

solid answer

~40 s

`getattr(obj, name)` performs exactly the lookup `obj.name` performs — same descriptors, same walk up the class hierarchy — except the name arrives as an ordinary `str` computed at run time, so it can come from a column header, a config key or a registry table. The three-argument form `getattr(obj, name, default)` returns `default` instead of raising `AttributeError`, which makes it the idiomatic "read this if it is there" probe. `setattr(obj, name, value)` and `delattr(obj, name)` are the write and delete halves of the same idea. Two limits matter: the default suppresses `AttributeError` only, so any other exception raised during the lookup still reaches the caller, and the name must be a `str` — anything else raises `TypeError`, not `AttributeError`.

code

python · 15 lines
python
class Message:
    def __init__(self, body):
        self.body = body

m = Message("hi")
print(getattr(m, "body"))
print(getattr(m, "edited_at", None))
setattr(m, "edited_at", "2026-09-05")
print(m.__dict__)
delattr(m, "edited_at")
print(hasattr(m, "edited_at"))
try:
    getattr(m, 42, None)
except TypeError as exc:
    print("TypeError:", exc)

go deeper

for a junior

Be ready to write the three-argument form from memory and say what its default protects against. Know that setattr and delattr are the write and delete halves of the same idea, and that the name is just a string.

for a middle

Explain that the function form runs the identical lookup as dotted syntax, only with a runtime name, and that the default suppresses AttributeError alone. Know the failure modes of setattr on slotted or read-only attributes.

for a senior

Show where a sprayed-on default turns a real failure into a quietly missing field, and how you keep name-driven reads auditable when a serializer or a registry is driving them across a large codebase.

for a principal

Own the design call: when attribute names start arriving as data from files or requests, decide whether a mapping or a declared schema is the honest structure, and where reflection over objects earns its keep versus hides a missing contract.

## The same lookup, with the name as data `obj.x` and `getattr(obj, "x")` are the same operation. Both run the full attribute lookup: data descriptors on the type first, then the instance's own `__dict__`, then plain class attributes and non-data descriptors up the method resolution order, and finally the type's fallback hook for missing names if it defines one. Nothing about the semantics changes. The one difference is *when the name is known*. The dotted form bakes the name into the compiled bytecode, so it must be written in the source. The builtin takes the name as an ordinary string argument, so it can be a value — a column header from a file, a key out of a config mapping, a field name stored in a registry, a string built two hundred lines away. That is the entire reason the builtin exists, and it is why every attribute-name-driven design in Python — serializers, object-to-row mappers, plugin registries, generic comparison helpers — bottoms out in it. ## The three-argument form `getattr(obj, name, default)` is defined as: do the lookup; if it raises `AttributeError`, return `default` instead. It is exactly this, written as an expression: ```python try: value = obj.x except AttributeError: value = default ``` Two consequences follow directly from that definition, and both are commonly missed. First, **only `AttributeError` is suppressed.** If reading the attribute runs code — a computed attribute, a lazy loader, any descriptor — and that code raises `ValueError`, `OSError` or `KeyError`, the default does not save you; the exception propagates. That is the correct design, but it surprises people who read the third argument as "never fail". Second, **the suppression is indiscriminate about *where* the `AttributeError` came from.** If a computed attribute's own body raises `AttributeError`, the default is returned and a real bug is silently reported as a missing field. This is the single largest hazard of the three-argument form, and the reason a default should be reserved for names you genuinely expect to be optional rather than sprayed over every dynamic read. ## setattr and delattr `setattr(obj, name, value)` is the assignment half: it is `obj.name = value` with the name as data. It respects the same machinery, so it can fail where plain assignment fails — on an instance of a class with `__slots__` that does not list the name, or when the type defines the attribute as a read-only computed one: ```python class S: __slots__ = ("a",) setattr(S(), "b", 1) # AttributeError: 'S' object has no attribute 'b' and no __dict__ for setting new attributes ``` `delattr(obj, name)` is `del obj.name`. It removes the name from the *instance*; it cannot reach through to the class. Deleting a name the instance never carried — one that reads fine because it is defined on the class — raises `AttributeError`, which trips people who expect a delete to be idempotent. If an instance attribute was shadowing a class attribute, deleting it makes the class value visible again rather than making the name disappear. Unlike `getattr`, neither `setattr` nor `delattr` accepts a default; there is nothing to default to. ## Name typing and validation The name argument must be a `str`. Passing an `int`, `bytes` or `None` raises `TypeError: attribute name must be string`, not `AttributeError` — so a `try/except AttributeError` around a dynamic read will not catch a name that arrived in the wrong type from a parser. Nor is the name validated as an identifier: `setattr(obj, "has space", 1)` succeeds and puts an unreachable-by-syntax key in the instance dictionary, readable only through `getattr` afterwards. ## Cost, and when to reach for something else Since 3.11 CPython's specializing interpreter optimizes the dotted attribute instruction with an inline cache keyed to the object's shape. A call to the builtin is an ordinary function call and does not get that treatment, so in a hot loop the function form is measurably slower than the dotted form. Write `obj.x` when you know the name; that is not style advice, it is the faster path. The deeper judgement call is whether attribute names should be data at all. When names are arriving from outside — a file, a request, a config — a plain `dict` is often the honest structure, and reaching for reflection over objects is a sign that a schema is missing. Reflection is the right tool when the *objects* are the source of truth (a registry of handler classes, an object-to-record mapper) and the wrong tool when it is only being used to avoid writing the mapping down.

  • Does the default in getattr(obj, name, default) suppress every exception the lookup can raise?
    No — only `AttributeError`. If the attribute is computed and its code raises `ValueError`, `KeyError` or `OSError`, that exception propagates to the caller untouched. The default is defined as a `try/except AttributeError`, nothing broader, which is also why an `AttributeError` raised *inside* a computed attribute is silently reported as a missing name.
  • What happens if the name you pass to getattr is not a str?
    It raises `TypeError: attribute name must be string`, not `AttributeError`, and the three-argument default does not catch it. So a name that arrived in the wrong type from a parser escapes a `try/except AttributeError` guard. The name is also not checked as a valid identifier — `setattr(obj, "has space", 1)` succeeds and stores a key that no dotted expression can ever read back.
  • When does delattr(obj, name) raise even though obj.name reads fine?
    When the name lives on the class rather than in the instance's own dictionary. `delattr` removes an instance attribute; it will not reach up and delete a class attribute, so it raises `AttributeError` for a name the instance is merely inheriting. If the instance had shadowed a class attribute, deleting the instance entry uncovers the class value again instead of removing the name.

Dotted access is dialling a number you memorised; getattr is dialling a number read off a card someone hands you at run time — same phone system, but the digits arrive later, and you can decide what to do when nobody answers.

saying these in an interview costs you the question

  • Thinks the default swallows any exception the lookup raises
  • Believes getattr is only for hacks, never production code
  • Passes a non-string name and expects AttributeError
  • Assumes setattr always succeeds regardless of __slots__
  • Thinks delattr can remove a class attribute through an instance
  • Confuses the getattr builtin with a type's missing-name fallback hook

context

open as a page

Why does a decorated function report the wrapper's __name__ and __doc__, and how does functools.wraps fix it?

level: middleimportance: must knowfreq 65%

basics

~20 s

Decoration rebinds the name to the wrapper, a different function object with its own metadata, so name, qualname and doc are the wrapper's. Applying functools.wraps to the wrapper copies that metadata from the wrapped function and sets wrapped.

open as a page

What do a Python function's __defaults__ and __kwdefaults__ hold, and when are they evaluated?

level: middleimportance: must knowfreq 55%

basics

~20 s

defaults is a tuple of default values for the trailing positional-or-keyword parameters, or None when there are none. kwdefaults is a dict of defaults for keyword-only parameters. Both are evaluated once when the def executes and stored on the function object.

open as a page

What are the five inspect.Parameter kinds, and what does each allow at a call site?

level: middleimportance: must knowfreq 55%

basics

~20 s

inspect.Parameter has five kinds: POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD, VAR_POSITIONAL, KEYWORD_ONLY and VAR_KEYWORD. The kind says how an argument may be supplied — by position, by either, by name only, or swept into a catch-all — and .parameters lists them in that order.

open as a page

What does a Python function's __name__ hold, and how does __qualname__ differ from it?

level: juniorimportance: should knowfreq 45%

basics

~20 s

name is the bare name from the def statement. qualname is the dotted path to the definition inside its module, including enclosing classes and functions, with <locals> marking a function-local definition. module names the defining module.

open as a page

What does inspect.signature() return for a Python function, and what can you read off it?

level: juniorimportance: should knowfreq 40%

basics

~10 s

inspect.signature() returns an immutable Signature object. Its .parameters attribute is an ordered, read-only mapping of names to inspect.Parameter objects, each carrying .name, .kind, .default and .annotation, and .return_annotation describes the return.

open as a page

What does hasattr() hide when a computed attribute raises AttributeError?

level: middleimportance: should knowfreq 45%

basics

~20 s

hasattr actually evaluates the attribute and returns False if the lookup raises AttributeError, including one raised by the attribute's own code. A broken computed attribute is therefore reported as an absent one; other exception types still propagate.

open as a page

How does inspect.Signature.bind differ from bind_partial, and when do you use each?

level: middleimportance: should knowfreq 45%

basics

~20 s

bind() maps a complete call onto the signature and raises TypeError if a required argument is missing; bind_partial() allows omissions. Both reject unknown names and surplus positionals, and neither fills in defaults until you call apply_defaults() on the result.

open as a page

Why does a serializer built on vars(obj) drop fields that dir(obj) lists?

level: seniorimportance: should knowfreq 40%

basics

~20 s

vars(obj) returns only the instance's own dict, so class attributes, computed properties and slot-based fields never appear. dir(obj) merges the whole class hierarchy with the instance and returns a sorted list of names, not values.

open as a page

How do you attach custom attributes to a function object, and when does a wrapper lose them?

level: seniorimportance: should knowfreq 35%

basics

~20 s

A plain Python function has a dict, so a decorator can just assign fn.some_flag = value and later read it back. A wrapper that replaces the function loses those attributes unless functools.wraps merges the wrapped function's dict into it.

open as a page

Why does inspect.signature see through functools.wraps, and when do you call inspect.unwrap?

level: seniorimportance: should knowfreq 38%

basics

~20 s

functools.wraps sets wrapped on the wrapper, and inspect.signature follows that chain by default, so a decorated function still reports the inner function's parameters. Pass follow_wrapped=False for the wrapper's own signature; inspect.unwrap walks the chain explicitly.

open as a page

When is operator.attrgetter a better sort key than an equivalent lambda?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

operator.attrgetter builds a C-level callable that fetches named attributes: it walks dotted paths, returns a tuple for several names, and can be pickled — none of which a lambda offers. A lambda still wins when the key needs a transformation.

open as a page