skip to content

Is ast.literal_eval(repr(x)) a safe way to round-trip Python data?

level: middleimportance: nice to knowfreq 18%

answer

  1. One side is a debugging aid
  2. Where practical is doing heavy lifting
  3. Constructor reprs are calls, not literals
  4. Two float values come back as names
  5. A cycle round-trips silently wrong

basics

~20 s

No. repr is a debugging aid, not a serialization format. Only literal-shaped values survive: nan and inf come back as bare names, Decimal, datetime and frozenset repr as calls, and all of those raise on the way back in.

solid answer

~40 s

It round-trips exactly the values whose `repr` happens to be a literal: strings, bytes, numbers, booleans, `None`, and containers built from them — floats included, since `repr` of a float has produced the shortest exactly-reversible string since 3.1. Everything else breaks. `float('nan')` and `float('inf')` repr as the bare names `nan` and `inf`, which the whitelist refuses. `Decimal('1.5')`, `datetime.date(2026, 1, 1)`, `frozenset({1})` and `range(0, 3)` repr as constructor calls, also refused. A default object repr, `<pkg.Thing object at 0x...>`, is not an expression at all, so you get `SyntaxError`. Worst of all, a self-referential list reprs as `[1, [...]]`, which parses — into a list containing `Ellipsis`. If a round trip must hold, choose a real format and an explicit conversion.

code

python · 10 lines
python
import ast
import datetime
import decimal

for value in ({"lang": "fr"}, float("nan"), decimal.Decimal("1.5"), datetime.date(2026, 1, 1)):
    text = repr(value)
    try:
        print(text, "->", repr(ast.literal_eval(text)))
    except ValueError:
        print(text, "-> refused")

go deeper

for a junior

Know that repr exists for reading and debugging. If you need to store data and read it back, pick a real format rather than trusting that the printed form will parse.

for a middle

Be able to name concrete values that break the round trip — nan, infinity, Decimal, datetime, frozenset, a plain object — and say which raise ValueError, which raise SyntaxError, and which fail silently.

for a senior

Frame it as an accidental persistence format: no schema, no version field, no cross-language reader, and a failure that surfaces in the reader long after the writer introduced the new value type.

for a principal

Rule out repr-based persistence at review time and require an explicit, versioned encoding for anything stored or exchanged, so a class changing its repr can never become a data-migration incident.

### What `repr` promises, and what it does not The documented contract of `repr` is that it returns "a string containing a printable representation of an object", and that *where practical* that string should look like a valid Python expression which recreates the object. "Where practical" is doing an enormous amount of work in that sentence. `repr` is a debugging aid; it is not, and was never specified as, a serialization format. `ast.literal_eval(repr(x))` therefore round-trips a *subset* of values, and the boundary of that subset is not obvious from the outside. ### What does round-trip Scalars and containers built entirely from literal-representable parts: * `str`, `bytes`, `int`, `bool`, `None`, `Ellipsis`; * `float` — and this one is exact: since 3.1, `repr` of a float produces the shortest string that reads back to the identical value, so `ast.literal_eval(repr(0.1)) == 0.1`; * `complex` — `repr(1+2j)` is `'(1+2j)'`, which the converter's complex special case accepts; * `list`, `tuple`, `dict` and non-empty `set` displays whose members all round-trip, plus the empty `set()`, which the converter special-cases. ### What does not * **`float('nan')` and `float('inf')`** repr as the bare names `nan` and `inf`. They are `Name` nodes, so `ast.literal_eval` raises `ValueError`. This is the classic production surprise: the data round-trips for a year, then one division produces an infinity and the reader starts throwing. * **Anything whose repr is a constructor call** — `Decimal('1.5')`, `datetime.date(2026, 1, 1)`, `frozenset({1})`, `range(0, 3)`, most named tuples. These reprs are exactly the "valid expression that recreates the object" the docs suggest, and they are all `Call` nodes, which the whitelist refuses. * **Recursive containers.** Build `a = [1]; a.append(a)` and `repr(a)` is `'[1, [...]]'`. That parses (it is a list containing `Ellipsis`), so you get a *wrong value* back rather than an error — the only case in this list that fails silently. * **Ordinary objects** without a custom `__repr__`, whose default repr is `<pkg.Thing object at 0x102ab3d10>`. That is not an expression at all, so you get `SyntaxError`, not `ValueError` — a second reason the call site must catch both. * **Anything with a custom `__repr__` written for humans**, which is most domain classes. A class is free to return whatever string it likes. ### Why this matters in practice The pattern shows up as a homegrown persistence layer: a job writes `repr(mapping)` into a text column, a later job reads it back with `ast.literal_eval`. It works, so it spreads, and it acquires the properties of a serialization format without any of the guarantees of one — no schema, no version field, no cross-language reader, no defined behaviour when a value type changes. The failure mode is that a new value type enters the data and the *reader* breaks, far from the writer that caused it, often in a batch job that has already written half its output. ### Two more properties that quietly matter Ordering is one. Dicts have been insertion-ordered since **3.7**, so a mapping's `repr` preserves key order and the round trip preserves it too; sets carry no order at all, so a set that goes out one way can come back in another, and any test asserting on the exact stored text will be flaky. Stability is the other: nothing obliges a class to keep its `__repr__` parseable, or even unchanged, between releases. A library that improves its debugging output in a patch version has, under this pattern, silently changed your storage format — and the break lands in whatever reads the old rows. ### What to do instead Pick an explicit format and an explicit conversion step. `json.dumps` with a `default=` hook, and a matching `object_hook` on the way back, makes the type mapping visible in code — you decide that a date becomes an ISO-8601 string, not `repr`. If the data must stay Python-shaped, define the translation yourself rather than inheriting whatever `__repr__` happens to emit today. The interview-shaped version of the answer is short: `ast.literal_eval(repr(x))` is a round trip for literal-shaped values only, it fails loudly on constructor reprs and non-numbers like `nan`, it fails *silently* on recursive containers, and no class is under any obligation to keep its `repr` parseable between releases. If a round trip has to hold, write the format down.

  • Which of these failures is the dangerous one, and why?
    The recursive container. `a = [1]; a.append(a)` reprs as `'[1, [...]]'`, and that is valid literal syntax — a list containing `Ellipsis`. You get a value back, silently different from the one you stored. Every other failure raises, and a raised error you can see; a wrong value read back into a pipeline you cannot.
  • Does repr of a float lose precision on the way back?
    No. Since 3.1, CPython's float repr produces the shortest decimal string that reads back to the identical double, so `ast.literal_eval(repr(0.1)) == 0.1` holds exactly. That is a property of CPython's repr algorithm, not a guarantee of the round trip in general — nan and inf still fail, since they repr as names rather than numbers.
  • What would you use instead for storing a mapping in a text column?
    `json.dumps` with an explicit `default=` hook, and an `object_hook` on the read side. That puts the type mapping in your code — you decide a date becomes an ISO-8601 string — gives you a cross-language reader, and lets you add a version field. The repr-plus-literal-eval pattern is a serialization format with none of those.

saying these in an interview costs you the question

  • Calls repr a serialization format
  • Assumes every repr is a valid literal
  • Expects nan and inf to round-trip
  • Forgets that a default object repr raises SyntaxError
  • Misses that a self-referential container fails silently

context