Working With Data

JSON & Files

JSON & Files

Agent work is mostly moving structured data around. JSON is the format that data arrives in, and files are where it rests. Neither is complicated; both have a few traps worth meeting deliberately.

JSON is a dictionary that has been turned into text

{"role": "user", "content": "Hello", "tokens": 12}

That is text. To work with it in Python you turn it into a dictionary, and to send it anywhere you turn it back.

Two function names carry the whole library:

import json

text = '{"role": "user", "content": "Hello"}'

data = json.loads(text)
print(data["role"])

back = json.dumps(data)
print(back)
  • loadsload string: text becomes a Python object
  • dumpsdump string: Python object becomes text

The trailing s means "string". Without it, json.load and json.dump work on an open file rather than a string. That single letter is the most common mix-up on this page.

How types map

JSONPython
objectdict
arraylist
stringstr
numberint or float
true / falseTrue / False
nullNone

The capitalisation of true and null is the visible difference. If you hand-write JSON inside a Python file and use True, it will not parse.

Reading nested data

Real responses nest, and this is where people get stuck.

response = {
    "status": "success",
    "ticket": {"id": 4471, "severity": "P2", "tags": ["network", "urgent"]},
}

print(response["ticket"]["severity"])
print(response["ticket"]["tags"][0])

Work left to right, one step at a time. response["ticket"] is a dictionary, so you can index it again. ["tags"] is a list, so you index it by position.

When you are unsure of the shape, print it:

print(json.dumps(response, indent=2))

indent=2 prints it laid out over multiple lines. This one line will save you more time than anything else on this page.

Missing keys

response["ticket"]["owner"]
KeyError: 'owner'

Square brackets demand the key exists. When it might not, use .get():

owner = response["ticket"].get("owner", "unassigned")

For anything that came from a model or an external API, assume keys may be missing. This is the single most common crash in agent code, and .get() with a sensible default removes most of it.

Reading and writing files

Always use with. It closes the file for you, even if something fails halfway.

with open("notes.txt", "r", encoding="utf-8") as f:
    text = f.read()

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")

The mode is the second argument:

ModeMeaning
"r"read, fails if missing
"w"write, wipes the file first
"a"append to the end
"x"create, fails if it exists

"w" deletes the existing contents the moment the file is opened. If you meant to add to a log, you want "a".

Always pass encoding

open("notes.txt", encoding="utf-8")

Without it, Python uses the machine's default, which differs between Windows and Mac. A file written on one and read on the other can raise UnicodeDecodeError, and any Indian-language text or an emoji in a prompt will trigger it. Pass encoding="utf-8" every time and the problem never appears.

Reading a large file

with open("huge.log", encoding="utf-8") as f:
    for line in f:
        if "ERROR" in line:
            print(line.strip())

Looping over the file reads one line at a time. f.read() loads the whole thing into memory and will fall over on a large log.

JSON files

with open("config.json", encoding="utf-8") as f:
    config = json.load(f)

with open("results.json", "w", encoding="utf-8") as f:
    json.dump(results, f, indent=2)

Here the names have no s, because they take a file rather than a string.

indent=2 when writing makes the file readable and diff-friendly in git. Worth it for anything a human will open.

JSONL, and why agent work uses it

A .jsonl file holds one JSON object per line. Golden datasets, eval results and trace logs almost always use it.

with open("dataset.jsonl", "w", encoding="utf-8") as f:
    for row in rows:
        f.write(json.dumps(row) + "\n")

with open("dataset.jsonl", encoding="utf-8") as f:
    rows = [json.loads(line) for line in f]

The advantage over one big JSON array: you can append a record without rewriting the file, and you can read it line by line however large it grows.

When the model returns almost-JSON

A model asked for JSON will sometimes wrap it in a code fence:

{"severity": "P1"}

json.loads on that fails. You can strip the fence:

cleaned = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
data = json.loads(cleaned)

But the better answer is not to be in this position. Use structured output with a Pydantic model and the framework guarantees the shape. Reach for string-cleaning only when you have no choice — it is the fragile path, and it is where a lot of flaky agent code comes from.

Paths

from pathlib import Path

data_dir = Path("data")
file = data_dir / "config.json"

print(file.exists())
text = file.read_text(encoding="utf-8")

pathlib builds paths with / and works on Windows and Mac alike. It saves you from "data" + "\\" + "config.json" and the bugs that come with it.

Coming from Java or C#

TaskJavaC#Python
Parse JSONJackson readValueJsonSerializer.Deserializejson.loads
Write JSONwriteValueAsStringSerializejson.dumps
Read a fileFiles.readStringFile.ReadAllTextopen(...).read()
Auto-closetry-with-resourcesusingwith
PathsPath.ofPath.Combinepathlib.Path

One real difference: Jackson and System.Text.Json map straight onto a typed class. Python's json gives you a plain dictionary with no checking at all. If you want the typed-object experience, that is what Pydantic is for — parse with json.loads, then validate with a model.

Common mistakes

  • load vs loads. The s means string.
  • Opening with "w" when you meant "a", and wiping the file.
  • No encoding="utf-8", then a UnicodeDecodeError on someone else's machine.
  • data["key"] on data you did not create. Use .get().
  • f.read() on a huge file.
  • Hand-parsing model output instead of using structured output.

Practise this

  • Turn a dictionary into JSON text and back again, and print it with indent=2.
  • Read a nested response and pull out a value three levels deep.
  • Write five records to a .jsonl file, then read them back into a list.
  • Trigger a KeyError, then fix it twice — once with .get() and once with an if.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# JSON is a dictionary that has been turned into text.
2# The trailing `s` means "string": loads/dumps take text, load/dump take a file.
3
4import json
5from pathlib import Path
6
7# --- text -> Python, and back ---
8text = '{"role": "user", "content": "Hello", "tokens": 12}'
9data = json.loads(text)
10print(type(data), data["role"])
11
12back = json.dumps(data)
13print(back)
14print()
15
16# --- Types map like this ---
17sample = {"s": "text", "i": 1, "f": 1.5, "b": True, "n": None, "list": [1, 2]}
18print("python -> json:", json.dumps(sample))
19print("note True became true, None became null")
20print()
21
22# --- Reading nested data ---
23response = {
24 "status": "success",
25 "ticket": {"id": 4471, "severity": "P2", "tags": ["network", "urgent"]},
26}
27print(response["ticket"]["severity"])
28print(response["ticket"]["tags"][0])
29print()
30
31# When you do not know the shape, PRINT IT LAID OUT. This saves hours.
32print(json.dumps(response, indent=2))
33print()
34
35# --- Missing keys ---
36try:
37 print(response["ticket"]["owner"])
38except KeyError as e:
39 print("KeyError:", e)
40print("safe:", response["ticket"].get("owner", "unassigned"))
41print()
42
43# --- Files: always `with`, always encoding ---
44Path("results.json").write_text(json.dumps(response, indent=2), encoding="utf-8")
45
46with open("results.json", encoding="utf-8") as f:
47 loaded = json.load(f) # no `s` - it takes a file
48print("read back:", loaded["ticket"]["id"])
49print()
50
51# --- Modes. "w" WIPES the file first. ---
52with open("notes.txt", "w", encoding="utf-8") as f:
53 f.write("first line\n")
54with open("notes.txt", "a", encoding="utf-8") as f: # "a" appends
55 f.write("second line\n")
56print(Path("notes.txt").read_text(encoding="utf-8"))
57
58# --- JSONL: one JSON object per line. Used for datasets and eval results. ---
59rows = [
60 {"question": "server down?", "expected": "check the logs"},
61 {"question": "slow export?", "expected": "check the scheduler"},
62]
63with open("dataset.jsonl", "w", encoding="utf-8") as f:
64 for row in rows:
65 f.write(json.dumps(row) + "\n")
66
67with open("dataset.jsonl", encoding="utf-8") as f:
68 loaded_rows = [json.loads(line) for line in f]
69print("jsonl rows:", len(loaded_rows), loaded_rows[0]["question"])
70print()
71
72# --- Reading a large file one line at a time ---
73with open("dataset.jsonl", encoding="utf-8") as f:
74 for line in f: # not f.read() - that loads the whole thing
75 print("streamed:", json.loads(line)["question"])
76print()
77
78# --- When a model wraps its JSON in a code fence ---
79messy = '```json\n{"severity": "P1"}\n```'
80cleaned = messy.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
81print("recovered:", json.loads(cleaned))
82print("...but structured output with a Pydantic model avoids this entirely.")