Skip to content
aviral gupta

// A5.4 · ~35 min · Advanced

eval, pickle and shell=True

After this lesson you can turn text into data with ast.literal_eval or json instead of eval, explain why unpickling untrusted bytes can run code, and start other programs with subprocess.run and an argument list instead of a shell.

Lesson 4 of 5 in A5 Modern Python and safe code

You will be able to

  • Replace eval with ast.literal_eval or json when turning text into data
  • Explain why unpickling untrusted data can run code, and use json for data from outside
  • Run other programs with subprocess.run and an argument list instead of shell=True
  1. Warm-up · Activity 1 of 7

    Warm-up from the previous lesson: the input is bound to a placeholder. What does this print?

    import sqlite3
    
    con = sqlite3.connect(":memory:")
    print(con.execute("SELECT ?", ("x' OR '1'='1",)).fetchone())
  2. Predict · Activity 2 of 7

    Predict before you read on: a program "calculates" what the user typed with eval. What does this print?

    text = "print('I ran') or 42"  # typed by a user
    print(eval(text))  # the mistake: eval runs any expression
  3. Practice · Activity 3 of 7

    Fill in the ast function that turns the text into data without running any code.

    import ast
    
    text = "{'a': [1, 2], 'b': None}"
    data = ast.____(text)
    print(data["a"][1], data["b"])
    data = ast.(text)
  4. Practice · Activity 4 of 7

    A class decides how it is unpickled. What does this print?

    import pickle
    
    
    class Loud:
        def __reduce__(self):
            return (print, ("unpickling ran print",))
    
    
    data = pickle.dumps(Loud())
    result = pickle.loads(data)
    print(result)
  5. Practice · Activity 5 of 7

    Match each job to the tool that does it safely.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Which inputs does literal_eval accept? What does this print?

    import ast
    
    for text in ["-5", "(1, 'a')", "2 + 3", "{1, 2}"]:
        try:
            print(ast.literal_eval(text), end=" ")
        except ValueError:
            print("rejected", end=" ")
  7. Apply · Activity 7 of 7

    Mini-task. A form sends a point as text, such as "(3, 4.5)". Write parse_point(text) that returns the tuple, and raises ValueError unless the text is a tuple of exactly two numbers (not bools). Use ast.literal_eval, never eval, and try "(1, 2, 3)", "('a', 1)" and "max(1, 2)".

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Three ways to load data, side by side

The program reads a settings literal with ast.literal_eval and shows that a call hidden in the text is rejected. It then round-trips data through json. Last, it unpickles an Alarm object whose __reduce__ names print: the message appears during pickle.loads, before your code sees any object. A crafted pickle can name a harmful function in the same way; here it only prints.

main.py

import ast
import json
import pickle


class Alarm:
    """Stands in for a crafted object: unpickling it calls print."""

    def __reduce__(self):
        return (print, ("!! code ran while unpickling",))


# 1. Text holding a Python literal: ast.literal_eval, never eval.
settings = ast.literal_eval("{'size': (800, 600), 'tags': ['a', 'b'], 'debug': False}")
print(settings["size"][0] * settings["size"][1], settings["tags"])
try:
    ast.literal_eval("__import__('os').getcwd()")
except ValueError as err:
    print("rejected:", str(err).split(":")[0])

# 2. Data from outside the program: json can only build data.
text = json.dumps({"user": "ada", "scores": [3, 5]})
print(text)
print(json.loads(text)["scores"])

# 3. pickle can call functions while loading: only for data you trust.
blob = pickle.dumps(Alarm())
pickle.loads(blob)

Run it with

python main.py

Output

480000 ['a', 'b']
rejected: malformed node or string on line 1
{"user": "ada", "scores": [3, 5]}
[3, 5]
!! code ran while unpickling
  • literal_eval builds tuples, lists, dicts and booleans, and nothing else.
  • The __import__('os') call was refused with ValueError, not run.
  • json.dumps writes the text; json.loads can only build data from it.
  • The last line is printed by pickle.loads itself.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

Settings without eval

parse_settings(text) uses eval, so text such as "{'n': len('abc')}" runs code. Rewrite it with ast.literal_eval. Raise ValueError when the text is not a literal, and also when the result is not a dict whose keys are all strings.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    import ast, then value = ast.literal_eval(text). It already raises ValueError for calls and names.

  2. Hint 2

    After parsing, check isinstance(value, dict) and all(isinstance(key, str) for key in value).

  3. Hint 3

    Raise ValueError yourself when either check fails.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import ast


def parse_settings(text: str) -> dict[str, object]:
    """Turn text such as "{'width': 80, 'tags': ['a']}" into a dict."""
    value = ast.literal_eval(text)  # ValueError for anything that is not a literal
    if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
        raise ValueError("settings must be a dict with str keys")
    return value
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def parse_settings(text: str) -> dict[str, object]:
    """Turn text such as "{'width': 80, 'tags': ['a']}" into a dict."""
    # eval runs any expression in text. Use ast.literal_eval, and raise
    # ValueError unless the result is a dict with only str keys.
    return eval(text)

test_main.py

from main import parse_settings


def rejects(text):
    try:
        parse_settings(text)
    except ValueError:
        return True
    return False


def test_literal_dict():
    """A dict of literals is parsed"""
    got = parse_settings("{'width': 80, 'tags': ['a', 'b'], 'theme': None}")
    assert got == {"width": 80, "tags": ["a", "b"], "theme": None}, f"parse_settings gave {got!r}"


def test_rejects_call():
    """A function call in the text is refused, not run"""
    assert rejects("{'n': len('abc')}"), "{'n': len('abc')} was accepted: eval ran len(); use ast.literal_eval"


def test_rejects_name():
    """A variable name in the text is refused"""
    assert rejects("{'n': width}"), "a name in the text was accepted or raised the wrong error"


def test_rejects_non_dict():
    """A list is not settings"""
    assert rejects("[1, 2]"), "[1, 2] was accepted; raise ValueError unless the result is a dict"


def test_rejects_non_str_key():
    """Keys must be strings"""
    assert rejects("{1: 'a'}"), "{1: 'a'} was accepted; every key must be a str"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 3

Scores as JSON, not pickle

dump_scores and load_scores exchange scores with other programs as hex-encoded pickles, so load_scores would run whatever a crafted pickle names. Switch both to json: dump_scores returns JSON text, and load_scores raises ValueError unless the text is a JSON object whose values are all integers (not booleans).

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    json.dumps(scores) and json.loads(text). json.JSONDecodeError is a subclass of ValueError, so bad text already raises it.

  2. Hint 2

    A hex string is not valid JSON, so a pickle is refused before anything in it could run.

  3. Hint 3

    Check isinstance(data, dict), then every value with isinstance(v, int) and not isinstance(v, bool).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json


def dump_scores(scores: dict[str, int]) -> str:
    """Save scores as text that another program can read back."""
    return json.dumps(scores)


def load_scores(text: str) -> dict[str, int]:
    """Read scores saved by dump_scores; raise ValueError for anything else."""
    data = json.loads(text)  # json.JSONDecodeError is a ValueError
    if not isinstance(data, dict):
        raise ValueError("scores must be a JSON object")
    for name, points in data.items():
        if not isinstance(points, int) or isinstance(points, bool):
            raise ValueError(f"score for {name!r} is not an integer")
    return data
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import pickle


def dump_scores(scores: dict[str, int]) -> str:
    """Save scores as text that another program can read back."""
    return pickle.dumps(scores).hex()


def load_scores(text: str) -> dict[str, int]:
    """Read scores saved by dump_scores; raise ValueError for anything else."""
    return pickle.loads(bytes.fromhex(text))

test_main.py

import json
import pickle

from main import dump_scores, load_scores


class Alarm:
    def __reduce__(self):
        return (print, ("alarm",))


def rejects(text):
    try:
        load_scores(text)
    except ValueError:
        return True
    return False


def test_round_trip():
    """Scores survive dump and load"""
    scores = {"ada": 3, "bob": 5}
    got = load_scores(dump_scores(scores))
    assert got == scores, f"load_scores(dump_scores(...)) gave {got!r}"


def test_readable_json():
    """dump_scores writes JSON that any program can read"""
    text = dump_scores({"ada": 3})
    try:
        got = json.loads(text)
    except ValueError:
        got = None
    assert got == {"ada": 3}, f"dump_scores wrote {text[:40]!r}, which is not JSON"


def test_refuses_pickle():
    """A pickle is refused with ValueError, and nothing in it runs"""
    blob = pickle.dumps(Alarm()).hex()
    assert rejects(blob), "load_scores accepted a pickle: it could run code while loading"


def test_checks_types():
    """A score that is not an integer is refused"""
    assert rejects('{"ada": "three"}'), 'load_scores accepted {"ada": "three"}'

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 3

Arguments without a shell

child_args(words) starts a second Python process and returns the arguments it received. The starter builds a shell command, so "two words" splits in two, $HOME is expanded and an apostrophe breaks the command. Pass a list to subprocess.run instead, with check=True. Run the tests on your own computer: the browser cannot start processes.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    The list is [sys.executable, "-c", CHILD, *words]: each word becomes exactly one argument.

  2. Hint 2

    Leave out shell=True; subprocess does not use a shell unless you ask for one.

  3. Hint 3

    Keep capture_output=True and text=True so result.stdout is a str for json.loads.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json
import subprocess
import sys

CHILD = "import json, sys; print(json.dumps(sys.argv[1:]))"


def child_args(words: list[str]) -> list[str]:
    """Run CHILD in a new Python process with words as its arguments; return what it received."""
    result = subprocess.run(
        [sys.executable, "-c", CHILD, *words],
        capture_output=True,
        text=True,
        check=True,
    )
    return json.loads(result.stdout)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import json
import subprocess
import sys

CHILD = "import json, sys; print(json.dumps(sys.argv[1:]))"


def child_args(words: list[str]) -> list[str]:
    """Run CHILD in a new Python process with words as its arguments; return what it received."""
    # A shell splits, expands and interprets the words. Pass a list instead.
    command = f'"{sys.executable}" -c "{CHILD}" ' + " ".join(words)
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return json.loads(result.stdout)

test_main.py

from main import child_args


def test_simple():
    """Two plain words arrive as two arguments"""
    got = child_args(["a", "b"])
    assert got == ["a", "b"], f"the child received {got!r}"


def test_spaces():
    """A word with a space stays one argument"""
    got = child_args(["two words"])
    assert got == ["two words"], f"the child received {got!r}: no shell, pass a list"


def test_special_characters():
    """$HOME and an apostrophe arrive unchanged"""
    got = child_args(["$HOME", "it's"])
    assert got == ["$HOME", "it's"], f"the child received {got!r}: a shell expanded or broke them"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A name inside the literal

import ast

size = 3
config = ast.literal_eval("[1, 2, size]")

What Python prints

ValueError: malformed node or string on line 1: Name(id='size', ctx=Load())

Why, and the fix

literal_eval has no variables: it cannot look up size. That is the point, so do not reach for eval. Parse the literal part and combine it in code, for example ast.literal_eval("[1, 2]") + [size].

Switching from pickle to json with a set

import json

tags = {"python", "security"}
print(json.dumps({"tags": tags}))

What Python prints

TypeError: Object of type set is not JSON serializable

Why, and the fix

JSON has objects, arrays, strings, numbers, true, false and null, but no sets, tuples or custom classes. Convert before dumping, for example sorted(tags), and convert back after loading. That limit is the reason json is safe: loading it can only build plain data.

A number in the argument list

import subprocess
import sys

count = 3
subprocess.run([sys.executable, "-c", "import sys; print(sys.argv)", count])

What Python prints

TypeError: expected str, bytes or os.PathLike object, not int

Why, and the fix

Every item of the argument list becomes a command-line argument, and those are always text. Convert first: str(count). Building one command string with f-strings instead would reopen the shell problems.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

eval runs code; literal_eval only reads literals

eval(text) evaluates any expression, so text from a user, a file or the network can call any function your program could call. The docs warn that this leads to security vulnerabilities, and that passing a restricted __builtins__ is not a security mechanism. To turn text into data, use ast.literal_eval: it accepts only literals (strings, bytes, numbers, tuples, lists, dicts, sets, booleans, None and Ellipsis) and raises ValueError for names, calls and operators. It never runs code, but very large input can still exhaust memory, so for data from outside, json is the better format.

Unpickling can call functions

pickle can store almost any Python object, because unpickling rebuilds it by calling functions that the data itself names. A class can choose them with __reduce__; here it returns (print, ("...",)), so pickle.loads calls print. A crafted pickle could name any function in the same way. The docs are blunt: never unpickle data that could have come from an untrusted source or been tampered with. Use pickle only for data your own program wrote and kept safe; for data that crosses a boundary, use json, which can only build dicts, lists, strings, numbers, booleans and None.

subprocess.run with a list, not a shell

subprocess.run(["prog", "arg one", name]) starts prog directly, and each list item arrives as exactly one argument: spaces, quotes, $ and ; are just characters. subprocess never calls a shell unless you pass shell=True. With shell=True and a command built from strings, the shell splits words, expands $HOME and treats ; as "run another command", which is shell injection. Add check=True to raise CalledProcessError on a non-zero exit, and capture_output=True, text=True to get stdout as a str. Starting processes needs your own Python: the browser cannot run subprocess.

Sources

Last reviewed September 29, 2026