Why can ast.literal_eval exhaust memory or CPU on untrusted input?
answer
- Two different safety properties
- The tree is built before the check
- Nesting is capped, size is not
- Megabytes of source, gigabytes of memory
- Cap length before the call
basics
~20 sIt guarantees no code runs, not that parsing is cheap. The parser builds a full syntax tree before the whitelist is consulted, so a large flat literal costs seconds of CPU and gigabytes of memory without executing anything.
solid answer
~40 sThe safety property is narrow: `ast.literal_eval` stops **code execution**, not **resource exhaustion**. CPython caps some abuses — deep nesting raises `SyntaxError: too many nested parentheses`, a long unary chain raises `MemoryError: Parser stack overflowed`, and an integer literal beyond 4300 digits raises `SyntaxError` from the integer/string conversion limit added in 3.11 (`sys.set_int_max_str_digits`). What is not capped is a big flat literal: on 3.14 a 6 MB source of three million integers takes about 8.5 seconds and peaks near 3 GB, holding the GIL in the C parser throughout. So cap the input length in bytes **before** calling, catch `ValueError`, `SyntaxError`, `TypeError`, `MemoryError` and `RecursionError` together, run the parse in a bounded worker with a timeout, and prefer a real format parser for foreign data.
code
python · 7 linesimport ast
for src in ("[" * 300 + "]" * 300, "-" * 100_000 + "1", "9" * 4400):
try:
ast.literal_eval(src)
except (SyntaxError, MemoryError) as exc:
print(type(exc).__name__, ":", str(exc).splitlines()[0][:52])go deeper
Take away the one distinction: safe from running code is not the same as safe from a huge input. Check how big a string is before you try to turn it into a value.
Explain that parsing builds a complete syntax tree before any whitelist check happens, and know the errors that pathological input produces — SyntaxError, MemoryError, RecursionError — not just ValueError.
Show the hardened call site: byte cap first, the whole exception family caught and translated, a bounded worker with a timeout, and a real format parser wherever the data is not Python source.
Own the availability side of input handling — request-size ceilings, per-worker memory limits and isolation — so one hostile or accidental payload degrades a single worker rather than the service.
### The guarantee is narrow, and the narrowness is the point `ast.literal_eval` guarantees that **no code from the string runs**. It says nothing about how much time or memory the string costs before that guarantee is even relevant, because all of the work happens in the tokenizer and parser — which run to completion, building a full abstract syntax tree, *before* the whitelist walk ever looks at a node. An attacker who cannot execute code can still choose exactly how expensive your parse is. ### Measured on CPython 3.14 * **A flat literal is the real weapon.** A 6 MB source string of the form `[1,1,1,...]` with three million elements parses to a list in about **8.5 seconds** and peaks near **3 GB** of allocated memory — roughly 500 bytes of peak memory per source byte. A single request-sized payload can push a container past its memory limit. During most of that the work is in the C parser, so the thread holds the GIL and the rest of the process starves too. * **Deep nesting is already capped.** `"[" * 1000 + "]" * 1000` does not overflow anything; the tokenizer refuses at a couple of hundred levels with `SyntaxError: too many nested parentheses`. * **A long unary chain hits the parser stack.** `"-" * 100000 + "1"` raises `MemoryError: Parser stack overflowed - Python source too complex to parse` — a `MemoryError` from a string containing nothing but minus signs. * **Huge integer literals are capped too.** A 4400-digit integer raises `SyntaxError: Exceeds the limit (4300 digits) for integer string conversion`. That limit arrived in **3.11** as a denial-of-service fix (quadratic decimal-to-binary conversion) and is readable and settable at runtime with `sys.get_int_max_str_digits` and `sys.set_int_max_str_digits`. So CPython has closed the *stack* attacks and the *integer* attack, and left the plain allocate-a-lot attack wide open — as it must, since a large valid literal is indistinguishable from a large valid document. ### A concrete failure A translation-memory updater stores each entry's metadata as a Python literal string and re-reads it with `ast.literal_eval` during a nightly consolidation run that takes about six hours. A guard was added to skip oversized entries, but its boundary check was off by one at the batch edge, so one entry above the intended cap slipped through on every pass. Most nights nothing happened; the night an upstream import produced a multi-million-element list, the worker's RSS climbed past three gigabytes and the run died four hours in, having already written half its output. Nothing was executed, nothing was exploited, and the job still failed — which is exactly the point: the security property held and the availability property did not. ### Hardening the call site 1. **Cap the input before parsing.** `len(text.encode())` against an explicit ceiling is the only check that runs in constant time; every limit inside the parser fires after the cost is paid. Size the ceiling to your real data (kilobytes, usually), not to what feels generous. 2. **Catch the whole exception family** — `ValueError`, `SyntaxError`, `TypeError`, `MemoryError` and `RecursionError` — and re-raise one domain error. `except ValueError` alone lets `SyntaxError` and `MemoryError` escape into whatever handler is above you. 3. **Bound the blast radius.** Do the parsing in a worker process with an OS-level address-space limit and a timeout, so an unlucky payload kills one worker instead of the service. 4. **Do not let a `MemoryError` be caught and continued.** Once allocation has failed, the process is in an unreliable state; the honest response is to fail the unit of work and, usually, the process. 5. **Use a real parser for foreign data.** `json.loads` costs roughly 30x less for the same document and reports errors with a position. If the string is JSON, none of this is your problem to solve. ### What an interviewer is listening for Two sentences, really: *"`ast.literal_eval` protects against code execution, not against resource exhaustion,"* and *"so untrusted input gets a length cap before the call and a bounded worker around it."* Candidates who answer "it's safe, that's what it's for" have learned the marketing line; candidates who name the tokenizer nesting cap, the 4300-digit integer limit and the un-capped flat allocation have actually read what the function does.
- Deeply nested brackets are the classic parser attack — does that one work here?No, CPython closed it. The tokenizer refuses beyond a couple of hundred nesting levels with `SyntaxError: too many nested parentheses`, and a long chain of unary minus signs hits the parser stack and raises `MemoryError: Parser stack overflowed`. Both fail fast and cheaply. The attack that still works is boring: a very large *flat* literal, which is indistinguishable from a large legitimate document.
- Why is a size cap in bytes better than a limit on the parsed result?Because every limit that lives inside or after the parse fires only once the cost is already paid — the tree exists, the memory is allocated. `len(text.encode())` against an explicit ceiling is a constant-time check that runs before any parsing starts. Size the ceiling to real data, typically kilobytes, and reject above it.
- Is catching MemoryError and continuing acceptable?Rarely. Once an allocation has failed the process is in an unreliable state — other allocations may have failed silently, and cleanup handlers can fail too. Catch it to fail the unit of work with a clear error, then let the worker exit and be replaced. Treating it as a routine retryable error is how a job limps along corrupting output.
saying these in an interview costs you the question
- Says it is safe, so untrusted input needs no limits
- Confuses no-code-execution with no-denial-of-service
- Puts the size check after the parse
- Catches only ValueError around the call
- Believes deep nesting still crashes the interpreter
- Retries indefinitely after a MemoryError