skip to content

How do you keep an API key field out of a Python dataclass's repr()?

level: middleimportance: must knowfreq 50%

answer

  1. The decorator writes a method for you
  2. That generated method prints every field
  3. One flag per field turns it off
  4. field(repr=False), but only for repr
  5. asdict and __dict__ still hold it

basics

~20 s

Declare the attribute with dataclasses.field(repr=False). The generated repr then omits it, and str() omits it too because it falls back to repr. The value is still an ordinary attribute, so dataclasses.asdict(), vars() and pickling still carry it.

solid answer

~50 s

The `@dataclass` decorator generates a `__repr__` that prints every field, which is exactly why a config or credentials dataclass leaks: one debug line, one exception message, one debugger pane, and the live key is in text. Declaring the attribute as `api_key: str = field(repr=False)` removes it from the generated `__repr__`, and because `str()` falls back to `__repr__` when no `__str__` exists, it disappears from `f"{obj}"` as well. What it does **not** cover is everything that reads the value rather than the representation: `dataclasses.asdict()`, `vars()`, `__dict__`, JSON or pickle serialization of that dict, and plain attribute access. If the secret travels through many objects, the stronger move is a small wrapper type whose own `__repr__` and `__str__` return a mask and which exposes the value only through an explicit accessor, so the masking follows the value everywhere.

code

python · 11 lines
python
from dataclasses import dataclass, field, asdict

@dataclass
class ExchangeCreds:
    account_id: str
    api_key: str = field(repr=False)

c = ExchangeCreds("bidder-7", "sk-live-8f3c")
print(repr(c))      # ExchangeCreds(account_id='bidder-7')
print(f"{c}")       # str() falls back to __repr__, also masked
print(asdict(c))    # the flag does not apply here: the key is back

go deeper

for a junior

Recall that @dataclass writes a repr printing every field, and that dataclasses.field(repr=False) removes one field from it. Know that printing an object calls that same method.

for a middle

Be ready to explain what the flag governs and what it does not: it edits one generated method, while asdict(), vars(), dict and pickling still expose the value. Mention that str() falls back to repr.

for a senior

Show the systemic fix. An interviewer expects you to reach for a secret wrapper type whose repr is masked and whose plaintext needs an explicit call, plus a test that fails if a credential's characters appear in a config object's repr.

for a principal

Own the convention across services: which types may hold plaintext, where the reveal boundary sits, and how review and tests enforce it, so a new dataclass added by any team cannot reintroduce the leak by default.

## Why a dataclass is the risky shape A plain Python class you write by hand inherits the default `object.__repr__`, which prints only the type name and an address. It leaks nothing. The moment you decorate the same class with `@dataclass`, the decorator generates a `__repr__` that renders **every** field, in declaration order, with each value's own `repr`. That is a genuine convenience for debugging, and it is why credentials leak from dataclasses more often than from hand-written classes: the danger arrives together with the convenience, silently, at the moment someone adds the decorator. The leak surface is wider than `print(obj)`. The generated representation is what shows up in an f-string with the `!r` conversion, in the value column of a debugger, in the `repr` of any container that holds the object (a list of client configs, a dict of tenants), in a crash reporter's frame-locals rendering, and in `%s`-style formatting, because when a class defines no `__str__` at all, `str()` falls back to `__repr__`. ## The mechanism Each field carries a `repr` flag that defaults to true. `dataclasses.field(repr=False)` sets it false for one attribute, and the generated `__repr__` simply omits that attribute from the rendered text. Nothing else changes: the field is still a normal `__init__` parameter, still compared by `__eq__` unless you also pass `compare=False`, still present in `__dict__`. ```python from dataclasses import dataclass, field @dataclass class ExchangeCreds: account_id: str api_key: str = field(repr=False) ``` Note that `field(repr=False)` supplies no default value, so it does not force the field to become optional and does not disturb the usual "fields without defaults come first" rule. There is a blunter variant: `@dataclass(repr=False)` at class level suppresses generation of `__repr__` entirely, leaving the safe `object` default. That is a reasonable choice for a class that is all secret and nothing else, at the cost of losing debuggability for its harmless fields. ## What repr=False does not do This is where the interview usually goes, and the honest answer is that the flag governs one method and only that method. `dataclasses.asdict(obj)` walks every field regardless of its `repr` flag and returns them all in a dict (recursing into nested dataclasses and deep-copying the leaves), so the secret is right there and will be in whatever JSON that dict becomes. `vars(obj)` and `obj.__dict__` expose it directly. `pickle` serializes the instance state, secret included. `dataclasses.replace()` reads it. Any code doing `obj.api_key` obviously reads it. And a structured logging library that serializes an object's attributes rather than calling `repr` on it is unaffected by the flag. So `repr=False` is a good, cheap guard against the accidental *debug-print* class of leak. It is not a security boundary, and describing it as one in an interview is a red flag of its own. ## The stronger pattern: make the value itself unprintable When a credential is passed around - into a client object, into a retry wrapper, into a task payload - masking it at one dataclass only protects that one dataclass. The pattern that scales is to give the secret its own tiny type whose `__repr__` and `__str__` both return a fixed mask, and which surrenders the plaintext only through a named method: ```python class Secret: __slots__ = ("_value",) def __init__(self, value): self._value = value def reveal(self): return self._value def __repr__(self): return "Secret(***)" __str__ = __repr__ ``` Now the masking travels with the value. It survives being put in a list, in a dict, in a dataclass someone writes next year, and in `asdict()` output, because what lands in the dict is the `Secret` object, not the string. The explicit `.reveal()` call is also a grep target: every place the plaintext is actually needed is now visible in a code search and in review, which is a much better control than remembering a flag on every declaration. The usual caveat applies: `reveal()` returns a `str`, and a `str` cannot be erased, so the wrapper narrows *where* the plaintext exists rather than removing it from the process. ## What to check for in review Three habits catch nearly all of this class of bug. Prefer a dedicated secret type over a bare `str` in any structure that will be printed. Where a bare `str` is unavoidable, mark it `field(repr=False)` at declaration time rather than after an incident. And write one small test that asserts the token's characters do not appear in `repr()` of the config object, so a future field added to the same dataclass cannot quietly reintroduce the leak.

  • Does field(repr=False) also keep the value out of dataclasses.asdict()?
    No. `asdict()` walks every field regardless of the repr flag, recursing into nested dataclasses and deep-copying leaf values, so the secret appears in the returned dict and in anything that serializes it. The same is true of `vars()`, `__dict__` and pickling. Only wrapping the value in a type that refuses to hand out its plaintext keeps it out of both the representation and the serialized form.
  • If a class defines no __repr__ at all, does the secret leak when it is printed?
    No. The default `object.__repr__` prints the class name and an address, nothing about the attributes, and `str()` falls back to it. The leak is introduced by the `__repr__` that `@dataclass` generates for you, which renders every field. That is why the same attribute in a hand-written class is quiet and in a dataclass is loud.
  • How would you stop this class of mistake systematically rather than one class at a time?
    Give secrets their own type whose `__repr__` and `__str__` return a mask and whose plaintext is only reachable through an explicit accessor, so masking travels with the value into every container and every future dataclass. Back it with a test asserting the credential's characters do not appear in `repr()` of the config objects, which catches a new field added later.

It is like redacting a name from the printed report while leaving it in the spreadsheet the report was generated from: the copy people read is clean, the source is not.

saying these in an interview costs you the question

  • Thinks field(repr=False) makes the attribute private or read-only
  • Believes a dataclass hides its fields unless told otherwise
  • Assumes dataclasses.asdict() honours the repr flag
  • Says repr=False encrypts, clears or protects the value
  • Claims str(obj) is safe even when the generated __repr__ leaks
  • Treats the flag as a security control rather than a debug guard

context