Parsing Structured Text - CSV and JSON
Context
Structured text stores records in an agreed layout. One common layout puts one record per line with fields separated by a chosen character and the field names on the first line, and another nests records the way collections nest in memory.
These two formats carry most of the text data you will load. A dataset exported from a spreadsheet or a database arrives in the first, and settings files, saved results, and anything from a web service tend to arrive in the second. Turning the first into a list of records keyed by column name is the step that makes a file behave like a table, and it is the step every later cleaning operation assumes has already happened.
Parsing by hand goes wrong in ways that look like clean data. Treating the header as a record adds a row of column names on top of your numbers, splitting on a separator that also appears inside a quoted field shifts every later field right, one place for each comma inside the quotes, for that row alone, and every value arriving as text means a sort silently treats 10 as smaller than 9. The previous lesson read a file into lines, this lesson turns those lines into records.
Goal
- Parse a single raw line into a list of structured fields by splitting and stripping.
- Split comma-separated lines into columns by hand, without relying on a library.
- Use the first line of a file as a header row that names the columns.
- Assemble a file into a list of record dictionaries, the canonical in-memory table shape.
- Load and dump nested data with the
jsonmodule, and choose between CSV-as-text and JSON for a given shape.
Prerequisites
Optional. This lesson assembles pieces you already have, so neither is needed, they are here if you want the JSON functions previewed.
- YouTube - "Python Tutorial: Working with JSON Data using the json Module" by Corey Schafer. Watch it once for
load/dumpandloads/dumps. - Official docs - The Python Tutorial, "Saving structured data with json". It shows
json.dumps,json.dumpandjson.load, three of the four core functions.
Table of Contents
- Line Parsing
- CSV-as-Text Parsing
- Header Row
- List-of-Dicts Records
- json Module
Theory
The list-of-dictionaries table you assemble in this lesson is one of the two common shapes a data-frame library (a library that holds a whole table as one object with named columns, pandas in Module 2) builds a table from, and you are building the manual version first so the tool never feels like magic. This is the section's integration task: it takes the string cleaning from 1.5.1, the file reading from 1.5.2, the type conversion from earlier in the module, and the nested structures from 1.4.4, and combines them into one loader.
Line Parsing
What. Line parsing is turning a single raw line of text into structured fields, separating the pieces and cleaning each one. It is the unit operation that everything else in this lesson repeats over many lines.
Why. A line read from a file is one undifferentiated string, like "1,setosa,5.1\n". Nothing useful can happen until that string is broken into its parts and each part is trimmed of noise. Line parsing is the bridge between "a line of text" and "a row of values", and getting this one line right is the whole battle, the rest is just doing it in a loop.
When. Parse a line whenever a single string carries multiple values that you need separately. Every simple record-oriented file whose separator is not whitespace, CSV and simple configs among them, is parsed one line at a time using exactly this pattern.
The pattern is strip the line, split it on the delimiter, then strip each field. The first strip removes the trailing newline, and the per-field strip removes padding the split leaves behind, both reflexes from the strings lesson.
raw = "1, setosa , 5.1 \n"
# 1) strip the whole line to drop the trailing newline and outer spaces.
line = raw.strip() # "1, setosa , 5.1"
# 2) split on the delimiter into raw fields.
parts = line.split(",") # ["1", " setosa ", " 5.1"]
# 3) strip each field of its own padding.
fields = [p.strip() for p in parts] # ["1", "setosa", "5.1"]
print(fields)Each field is still a string at this stage, so the final move is type conversion, casting each field from text to its true type. This is the type conversion concept from earlier in the module, now applied per field.
fields = ["1", "setosa", "5.1"]
# Cast each field from text to its real type.
record_id = int(fields[0]) # 1, an integer
label = fields[1] # "setosa", stays a string
measurement = float(fields[2]) # 5.1, a float
print(record_id, label, measurement) # 1 setosa 5.1
print(type(measurement)) # <class 'float'>Every field arrives as text, without exception, so a measurement stays the string "5.1" until you cast it. Strings compare and sort, which is what makes the omission dangerous, "10" < "9" is True and no error is raised anywhere.
DON'T:
- DON'T lean on
intandfloattolerating whitespace. They do,float("5.1 ")is5.1, but the check that decides whether to cast does not,"42\n".isdigit()isFalse, so an unstripped field takes the wrong branch and never reaches the cast at all. Strip first, always. - DON'T assume every field is the same type. An id is an int, a label is a string, a measurement is a float, cast each field to its own correct type.
CSV-as-Text Parsing
What. CSV-as-text parsing is reading a comma-separated-values file by splitting each line on commas yourself, treating the file as plain text rather than using a dedicated CSV library. Splitting on commas is the core of it. Why. CSV is the most common plain-text table format, a grid of rows and columns separated by commas. Parsing it by hand teaches exactly what a CSV is and what a CSV tool does for a simple file, so the tool you meet later is demystified. For clean, simple files, manual splitting is also perfectly sufficient. When. Use manual CSV-as-text parsing for simple, well-behaved files where fields contain no commas or quotes. When fields can contain commas inside quotes or embedded newlines, you reach for the dedicated tool (noted in the DON'T), but the manual version is the foundation to understand first.
A CSV file is just lines of comma-separated fields, so parsing it is the line parse from the previous subtitle applied to every line. You read the file and parse each line into a list of fields.
# Create a small CSV-shaped file for the example.
with open("data.csv", "w", encoding="utf-8") as f:
f.write("1,setosa,5.1\n")
f.write("2,versicolor,6.4\n")
f.write("3,virginica,5.9\n")
# Parse every line into a list of fields.
rows = []
with open("data.csv", "r", encoding="utf-8") as f:
for line in f:
fields = [p.strip() for p in line.strip().split(",")]
rows.append(fields)
print(rows) # [['1', 'setosa', '5.1'], ['2', 'versicolor', '6.4'], ['3', 'virginica', '5.9']]That is the entire mechanism of reading a CSV by hand, iterate the lines and apply the one-line parse to each.
The fragility of manual splitting shows up when a field itself contains the delimiter. A naive split on commas miscounts the fields of a line like 1,"Smith, Ada",5.1, which is exactly the case the dedicated tool handles.
# A field containing a comma inside quotes BREAKS a naive split.
tricky = '1,"Smith, Ada",5.1'
# Naive split sees FOUR fields, not three, the quoted comma fooled it.
print(tricky.split(",")) # ['1', '"Smith', ' Ada"', '5.1']A naive split has no concept of quoting, so it cuts at every comma including the one that belongs inside a field. Note the failure mode: four fields where three were expected, no error, and a name silently broken in half.
DON'T:
- DON'T use a naive comma split on files whose fields may contain commas or quotes. Those need the standard-library
csvmodule, which understands quoting, manual splitting silently miscounts fields when a quoted field holds a comma, and keeps the quote marks in every quoted value. - DON'T split a line without stripping it, either the whole line first or every field after. The last field would otherwise keep its trailing newline, corrupting the value.
Header Row
What. A header row is the first line of a file that names the columns rather than holding data. You read it once, separately, to learn the field names, then treat every remaining line as a data row.
Why. Positional fields are fragile and unreadable, fields[2] tells you nothing, and if a column is inserted, every index after it shifts. The header turns positions into names, so a value becomes record["sepal_length"] instead of record[2], which is self-documenting and robust to column reordering. The header is what lets you build named records.
When. Use the header whenever the file's first line names the columns, which is the norm for CSV data files. Read it first, outside the data loop, then pair it with each data line to produce named fields.
You read the header line first and parse it into a list of column names. Then you parse each remaining line and pair its values with those names. Reading the first line separately, then looping the rest, is the structure.
# Create a CSV WITH a header row as the first line.
with open("data.csv", "w", encoding="utf-8") as f:
f.write("id,label,length\n") # header: names the columns
f.write("1,setosa,5.1\n")
f.write("2,versicolor,6.4\n")
with open("data.csv", "r", encoding="utf-8") as f:
# Read and parse the FIRST line as the header.
# ["id", "label", "length"]
header = f.readline().strip().split(",")
# The loop now sees only the remaining (data) lines.
for line in f:
values = line.strip().split(",")
print(header, values)The key detail is that readline advances the file position, so the for loop that follows starts at the second line. That is what separates the names from the values without needing an index or a counter.
Pairing the header names with a row's values is the job of zip, which produces (name, value) pairs, the bridge to building a dictionary in the next subtitle.
header = ["id", "label", "length"]
values = ["1", "setosa", "5.1"]
# zip pairs each column name with its value, position by position.
pairs = list(zip(header, values))
print(pairs) # [('id', '1'), ('label', 'setosa'), ('length', '5.1')]zip stops at the shorter of the two, which matters here. A data row with a missing field produces a shorter record rather than an error, and unless the missing field is the last one, the values after the gap pair silently with the wrong names.
DON'T:
- DON'T let the header line fall into your data loop. Read it first with
readline(or skip index 0), or the first cast meets the header text andint("id")raisesValueError. - DON'T assume the header and each row have the same number of fields. A malformed row with too few or too many values can misalign with
zip, validate the lengths if the data is untrusted.
List-of-Dicts Records
What. A list-of-dicts is the canonical in-memory table shape: a list where each element is a dictionary mapping column names to that row's values. It is the nested structure from 1.4.4, a list of dictionaries, made real with file data.
Why. This shape mirrors how you think about a table: a sequence of rows, each row a record with named fields. It is readable (row["label"]), order-preserving (rows stay in file order), and it is one of the two common shapes a data-frame library builds a table from.
When. Assemble a list-of-dicts whenever you load a record-oriented file for processing, filtering rows, computing per-column statistics, transforming fields. It is the default target shape for a CSV loader.
You build it by combining everything so far: read the header, then for each data line, parse the values, pair them with the header via zip, and construct a dictionary. The dict(zip(...)) idiom builds one record in a single expression.
with open("data.csv", "w", encoding="utf-8") as f:
f.write("id,label,length\n")
f.write("1,setosa,5.1\n")
f.write("2,versicolor,6.4\n")
f.write("3,virginica,5.9\n")
records = []
with open("data.csv", "r", encoding="utf-8") as f:
# column names
header = f.readline().strip().split(",")
for line in f:
values = [v.strip() for v in line.strip().split(",")]
# dict(zip(names, values)) builds one record dictionary.
record = dict(zip(header, values))
records.append(record)
print(records[0]) # {'id': '1', 'label': 'setosa', 'length': '5.1'}
print(records[1]["label"]) # versicolor, access by NAMEReaching a value by name rather than by position is the payoff. records[1]["label"] says what it fetches, and it keeps working when someone inserts a column in front of it, which values[1] does not.
The records are still all-strings until you cast each field to its proper type, which is the type-conversion step folded into the build. A complete loader casts as it constructs each record.
records = []
with open("data.csv", "r", encoding="utf-8") as f:
header = f.readline().strip().split(",")
for line in f:
values = [v.strip() for v in line.strip().split(",")]
raw = dict(zip(header, values))
# Cast each field to its true type while building the typed record.
record = {
"id": int(raw["id"]), # text -> int
"label": raw["label"], # stays a string
"length": float(raw["length"]) # text -> float
}
records.append(record)
print(records[0]) # {'id': 1, 'label': 'setosa', 'length': 5.1}
print(records[0]["length"] + 1) # 6.1, now genuinely numericThis loader builds a raw all-strings record with dict(zip(...)), then constructs the typed record by casting each field, int for the id, float for the length, the label left as text. The proof is records[0]["length"] + 1 returning 6.1: the field is a real number, not the string "5.1", so arithmetic works. This typed list-of-dicts is the finished output of a manual CSV loader.
DON'T:
- DON'T leave the records as all strings if you will do math on them. A field stays text until you cast it, and
"5.1" + 1raisesTypeError. - DON'T hard-code field names in the cast if the schema varies, for fixed, known files explicit casts are clearest, but a varying schema needs a per-column rule rather than a hand-written dictionary.
json Module
What. The json module reads and writes data in JSON format, a text representation of nested structures. json.load/json.dump work with files, and json.loads/json.dumps work with strings. You must import json first, it is part of Python's standard library, so nothing needs installing.
Why. CSV is flat, rows and columns only, but real configurations and records are often nested: a model with a sub-dictionary of parameters, a record with a list of tags. JSON represents the nested lists-and-dicts structures you built in 1.4.4, as long as their keys are strings, and the json module converts between them and text automatically, so you never hand-parse nested data. It is the natural format whenever the data is not a flat table.
When. Use JSON when your data is nested or hierarchical, or when you want a faithful, exact round-trip of Python lists, dicts with string keys, numbers, and strings to a file and back. Use CSV-as-text when the data is a flat table of rows and columns.
import json makes the module available. json.dump(obj, file) writes a Python object to an open file as JSON, and json.load(file) reads it back into Python objects. The structure, nested dicts and lists, is preserved exactly.
# json is part of the standard library, import it first (no install needed).
import json
config = {
"model": "rf",
"params": {"depth": 5, "trees": 100}, # nested dict
"features": ["len", "width"], # nested list
}
# json.dump writes the object to an OPEN file as JSON text.
with open("config.json", "w", encoding="utf-8") as f:
json.dump(config, f, indent=2) # 'indent' pretty-prints for readability
# json.load reads it back into Python objects.
with open("config.json", "r", encoding="utf-8") as f:
loaded = json.load(f)
print(loaded["params"]["trees"]) # 100, nested access preserved
print(loaded == config) # True, faithful round tripThe round trip is faithful here because every value in config is of a type JSON has, strings, numbers, lists and dicts. That is a property of this particular object rather than a guarantee, and the DON'T below says where it stops holding.
The string variants json.dumps and json.loads (note the s for "string") convert to and from a JSON string instead of a file, useful for logging, network messages, or embedding JSON in other text.
import json
record = {"id": 1, "tags": ["a", "b"], "score": 0.9}
# dumps -> a JSON STRING (the 's' is for string).
text = json.dumps(record) # '{"id": 1, "tags": ["a", "b"], "score": 0.9}'
print(type(text)) # <class 'str'>
# loads -> parse a JSON STRING back into Python objects.
back = json.loads(text) # {'id': 1, 'tags': ['a', 'b'], 'score': 0.9}
print(back["tags"][1]) # bChoosing between JSON and CSV-as-text comes down to shape, a flat table goes to CSV and a nested structure goes to JSON.
DON'T:
- DON'T confuse the
svariants.dump/loadtake a file object,dumps/loadstake/return a string, passing a filename string tojson.loadraises an error. - DON'T expect JSON to preserve every Python type. Tuple values become lists, number keys become strings, and sets and tuple keys are not allowed, JSON has its own smaller type set, so a round trip can change tuples to lists.
Lab Exercises
Lab Exercise 1: Parse One Line, Then a Whole Headerless File
Given raw = " 7 , virginica , 6.3 \n", parse it with strip-split-strip into a clean list of three field strings, then cast them into an id as an int, a label as a string, and a length as a float. Print the three typed values and the types of the two you cast.
Then create points.csv holding the three lines 1,2, 3,4 and 5,6, with no header. Read it back, build a list of (x, y) tuples of integers, and print the list together with the sum of every y.
Finally, print what '1,"Smith, Ada",5.1'.split(",") returns and add a comment stating how many fields you expected and how many you got.
Lab Exercise 2: Build a Typed Table, Then Round-Trip It Through JSON
Create iris.csv with the header id,label,length and three data rows. Write a loader that reads the header separately, then builds a list of record dictionaries in which id is an int and length is a float, and print the label of the row with the largest length.
Then write that list of records to iris.json with the json module, read it back, and print whether the reloaded data equals what you wrote.
Finally, turn the same records into a JSON string rather than a file, and print the type of what you got back. Add a comment naming which pair of functions works on files and which on strings.
Solution 1: Parse One Line, Then a Whole Headerless File
raw = " 7 , virginica , 6.3 \n"
line = raw.strip() # drops the trailing newline and the outer spaces
parts = line.split(",") # cuts at the commas, keeps the padding
fields = [p.strip() for p in parts] # trims each field separately
print(fields) # ['7', 'virginica', '6.3']
record_id = int(fields[0]) # every field arrives as text
label = fields[1] # this one is genuinely text
length = float(fields[2])
print(record_id, label, length) # 7 virginica 6.3
print(type(record_id), type(length)) # <class 'int'> <class 'float'>with open("points.csv", "w", encoding="utf-8") as f:
f.write("1,2\n")
f.write("3,4\n")
f.write("5,6\n")
pairs = []
with open("points.csv", "r", encoding="utf-8") as f:
for line in f:
# cast, then unpack
x, y = [int(v) for v in line.strip().split(",")]
pairs.append((x, y))
print(pairs) # [(1, 2), (3, 4), (5, 6)]
print(sum([y for _, y in pairs])) # 12print('1,"Smith, Ada",5.1'.split(","))
# ['1', '"Smith', ' Ada"', '5.1']
# Three fields expected, four returned. The comma inside the quotes was cut too,
# because a plain split has no idea that quoting exists.DON'T:
- DON'T strip only the line and call the fields clean, the split leaves each field's own padding exactly where it was.
- DON'T reach for a plain comma split on a file whose text fields might contain commas, it miscounts silently rather than raising.
Solution 2: Build a Typed Table, Then Round-Trip It Through JSON
with open("iris.csv", "w", encoding="utf-8") as f:
f.write("id,label,length\n")
f.write("1,setosa,5.1\n")
f.write("2,versicolor,6.4\n")
f.write("3,virginica,5.9\n")
records = []
with open("iris.csv", "r", encoding="utf-8") as f:
# reads ONE line and advances past it
header = f.readline().strip().split(",")
for line in f: # so this loop starts at the first data row
values = [v.strip() for v in line.strip().split(",")]
raw = dict(zip(header, values)) # name each value by its column
records.append({
"id": int(raw["id"]),
"label": raw["label"],
"length": float(raw["length"]),
})
print(records[0]) # {'id': 1, 'label': 'setosa', 'length': 5.1}
# Comparing floats is fine here, we want the largest rather than an exact match.
best = records[0]
for r in records:
if r["length"] > best["length"]:
best = r
print(best["label"]) # versicolorimport json
# 'dump' and 'load' take an open FILE.
with open("iris.json", "w", encoding="utf-8") as f:
json.dump(records, f, indent=2)
with open("iris.json", "r", encoding="utf-8") as f:
reloaded = json.load(f)
print(reloaded == records) # True
# 'dumps' and 'loads' take and return a STRING, the 's' is for string.
text = json.dumps(records)
print(type(text)) # <class 'str'>
print(json.loads(text) == records) # True
# dump/load work on files, dumps/loads work on strings. Passing a filename to
# json.load will not open it, it expects an object it can read from.DON'T:
- DON'T let the header line reach your data loop, casting
int("id")raises and the message will not mention the header. - DON'T pass a path string to
json.load, it wants an open file object, andjson.loadsis the one that takes text.
Theory Assessment
Q1: The Three Steps, and Why the Last One Is Not Optional
Describe the three steps of parsing one raw line into clean fields, say what each step removes, and say which step the third makes redundant. Then explain why the resulting fields must be cast to their real types, and give a concrete way that skipping the cast produces a wrong answer rather than an error.
Hint: Two of the three steps are both called strip and they act on different things. For the second half, ask what happens when you sort or compare values that are still text.
Q2: The Comma Inside the Field, and the Line That Is Not Data
Explain what '1,"Smith, Ada",5.1'.split(",") returns and why, and say what that means for a loader that trusts the field count. Then explain why the header row has to be read separately, what happens if it reaches the data loop, and how readline makes the separation work.
Hint: For the first half, count the pieces and ask which of them are halves of one value. For the second, think about what casting the text "id" to a number would do.
Q3: Naming the Values, and Choosing the Format
Explain what dict(zip(header, values)) builds and why it is preferable to keeping a bare list of values. Then say when you would store data as CSV and when as JSON, in terms of the shape of the data rather than habit.
Hint: For the first half, ask what happens to values[2] when someone inserts a column at the front. For the second, one of the two formats has no way to express a value inside a value.
Q4: A Round Trip That Changed the Data
Read the following without running it, then state what each print shows.
import json
original = {1: "first", "coords": (3, 7), "scores": [0.5, 0.9]}
text = json.dumps(original)
back = json.loads(text)
print(text)
print(back == original)
print(type(back["coords"]), list(back.keys()))Nothing raises, and the data that comes back is not the data that went in. Identify both things that changed and explain why, then say what that means for using JSON as a save format.
Hint: JSON has a smaller set of types than Python does. Ask, for each value in original, whether JSON has somewhere to put it, and what the closest available thing is.
Answer 1:
The three steps are strip the whole line, split it on the delimiter, then strip each field. The first strip removes the trailing newline and any whitespace at the two ends of the line. The split removes the delimiters and nothing else. The second strip removes padding from the two ends of each field, which the split left untouched because it only ever cuts at the separator. It reaches the outer ends of the first and last fields too, so for a delimiter that is not whitespace, such as the comma, once every field is stripped the line strip removes nothing more, and it earns its place in the forms that skip the per-field strip, such as line.strip().split(",") alone, where it is the only thing that removes the newline.
Fields must be cast because every field arrives as text, whatever it looks like. Skipping the cast usually does not raise, which is the danger: strings compare and sort perfectly happily, so "10" < "9" evaluates to True because comparison is character by character. A loader that forgets to cast a numeric column can sort a table into the wrong order, or pick the wrong maximum, and report no problem at all.
DON'T strip the line and treat the fields as clean, and don't leave a numeric column as text because nothing complained.
Answer 2:
It returns ['1', '"Smith', ' Ada"', '5.1'], four pieces where the data had three fields. split cuts at every comma it finds and has no concept of quoting, so the comma inside the quoted name is treated as a separator like any other, breaking the name into two halves.
For a loader that trusts the field count and leaves fields as text, this is worse than a crash. A row that should produce three values produces four, so pairing it against a three-name header with zip silently drops the extra, and the record ends up holding "Smith as its name. The file is not malformed, the parser is simply wrong about it, which is why the standard csv module exists.
The header must be read separately because it is not data. If it reaches the data loop, the loader tries to cast the column name, and int("id") raises ValueError with a message about the text "id" that says nothing about a header. readline works because it reads exactly one line and advances the file position, so the for line in f: that follows begins at the second line without any index or counter.
DON'T assume a row's field count matches the header's, validate it when the data is not yours.
Answer 3:
dict(zip(header, values)) pairs each column name with the value in the same position and builds one record dictionary, so record["label"] replaces values[1]. It is preferable because the name says what it fetches, and because it survives change: insert a column at the front of the file and every positional index shifts by one, silently reading the wrong column, while every name still points at the right value.
Use CSV when the data is a flat table, rows and columns of single values, which is what CSV can express and all it can express. Use JSON when a value is itself a structure, a record with a list of tags or a config with a nested block of parameters, because JSON represents nested lists and dicts directly. The deciding question is whether any cell needs to contain something other than one scalar.
DON'T flatten nested data into CSV by inventing separators inside a field, that is the quoted-comma problem you would then have to parse yourself.
Answer 4:
text prints {"1": "first", "coords": [3, 7], "scores": [0.5, 0.9]}. back == original prints False. The last line prints <class 'list'> ['1', 'coords', 'scores'].
Two things changed. The integer key 1 became the string "1", because JSON object keys are always strings and there is nowhere else to put an integer key. And the tuple (3, 7) became the list [3, 7], because JSON has one sequence type and it is the one that maps back to a Python list.
Neither conversion raised, because neither is an error. JSON's type set is smaller than Python's, so serializing means mapping each value onto the closest thing JSON has, and both of these mappings are lossy in one direction: you can get from a tuple to a list, and you cannot tell afterwards which lists were tuples.
For using JSON as a save format, this means a round trip is faithful only when every value is already of a type JSON has, namely strings, numbers, booleans, None, lists and dicts with string keys. Data of exactly that shape survives perfectly, which is why the loader earlier in this lesson round-tripped cleanly. Data with tuples, integer keys, or sets does not, and a set does not survive at all, it raises TypeError on the way out rather than converting.
DON'T treat a successful json.dumps as proof the data will come back unchanged, check whether the types you put in are types JSON actually has.
Assignment
Assignment: A Config Loader With Defaults, and Two Merges That Lose Settings
Build the following in one file, in this order.
Write load_config(path) that reads a JSON config file into a dictionary and returns it merged over the defaults {"lr": 0.01, "epochs": 10, "batch": 32}, so that values present in the file win and missing keys fall back to the defaults. Demonstrate it by writing a partial config holding only lr and a nested "params" block, loading it, and printing the merged result.
Then demonstrate two ways this loses data, rather than describing them.
First, make a second set of defaults with a nested block, {"lr": 0.01, "params": {"depth": 3, "trees": 100}}, and load a config whose "params" block sets only depth. Print the merged result and add a comment stating what happened to trees and why merging at the top level cannot see inside a nested value.
Second, build a config object in Python that holds an integer key and a tuple value, write it out with json.dump, read it back with json.load, and print whether the reloaded object equals the original along with the type of the tuple's replacement. Add a comment naming both conversions. Then attempt to serialize a config containing a set inside a try, and print the error rather than letting it stop the script.
Hints:
{**defaults, **loaded}builds a new dictionary by laying the second over the first, so a key present in both takes the value fromloaded.- For the nested case, ask what
{**a, **b}does with a key whose value is itself a dictionary, it is the same rule applied to that one key and it does not descend.
Solution:
import json
DEFAULTS = {"lr": 0.01, "epochs": 10, "batch": 32}
def load_config(path):
with open(path, "r", encoding="utf-8") as f:
loaded = json.load(f)
return {**DEFAULTS, **loaded} # 'loaded' is laid over 'DEFAULTS', so it wins
with open("cfg.json", "w", encoding="utf-8") as f:
json.dump({"lr": 0.05, "params": {"depth": 5}}, f, indent=2)
print(load_config("cfg.json"))
# {'lr': 0.05, 'epochs': 10, 'batch': 32, 'params': {'depth': 5}}# FAILURE 1: the merge is one level deep.
NESTED_DEFAULTS = {"lr": 0.01, "params": {"depth": 3, "trees": 100}}
with open("cfg2.json", "w", encoding="utf-8") as f:
json.dump({"params": {"depth": 9}}, f)
with open("cfg2.json", "r", encoding="utf-8") as f:
loaded = json.load(f)
print({**NESTED_DEFAULTS, **loaded})
# {'lr': 0.01, 'params': {'depth': 9}}
# 'trees' is gone. The merge treats "params" as ONE key with one value, so the
# loaded dictionary replaced the default dictionary whole rather than merging
# into it. 'lr' survived only because the file never mentioned it.# FAILURE 2: JSON's type set is smaller than Python's.
original = {10: "num_classes", "input_shape": (28, 28)}
with open("cfg3.json", "w", encoding="utf-8") as f:
json.dump(original, f)
with open("cfg3.json", "r", encoding="utf-8") as f:
back = json.load(f)
print(back) # {'10': 'num_classes', 'input_shape': [28, 28]}
print(back == original) # False
# <class 'list'> ['10', 'input_shape']
print(type(back["input_shape"]), list(back.keys()))
# The integer key 10 came back as the string "10", and the tuple came back as
# a list. Both are conversions rather than errors, so nothing was raised.
# A set has no JSON equivalent at all, so this one does raise.
try:
json.dumps({"tags": {"a", "b"}})
except TypeError as e:
print("TypeError:", e) # TypeError: Object of type set is not JSON serializableThe two failures are worth contrasting because only one of them is JSON's doing.
The nested merge has nothing to do with files or formats. {**a, **b} copies key-value pairs, and for a key present in both it takes b's value entire, whatever that value happens to be. When the value is a dictionary, "entire" means the whole dictionary, so a partial override silently discards every sibling setting. A config file that sets one nested option and unknowingly clears four others is a genuinely nasty bug, and the merge is doing exactly what it says.
The type conversions are JSON's doing, and they are the price of a format that has to be readable by anything. A set raises, which is the good case because it stops you. An integer key and a tuple value do not raise, they quietly become the nearest JSON thing, and the loss stays silent until you compare against the original, check a type, or use a key or value the way the original was used. That is why the equality check on the round trip is worth writing even when you are confident.
DON'T use a top-level merge to combine configs with nested sections, and don't treat a successful json.dump as evidence the data will come back as what you sent.
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want the proper CSV tool or the full JSON reference.
- Official docs -
csv, thereaderandDictReaderentries, the tool that handles quoted commas. - Official docs -
json, "Encoders and Decoders", the conversion table showing which Python types map to which JSON types.
Summary
This lesson is where the pieces of the whole section lock together. The strings lesson gave you strip and split, and Section 1.1 gave you the cast. The files lesson gave you a safe way to read text off the disk a line at a time. On their own each was a fragment, and here they combine into the single most important routine a data practitioner writes by hand: a loader that takes a file of comma-separated lines and returns a clean, typed, named table. Every step in that loader is something you already knew, strip the line, split on the delimiter, strip each field, pair the values with the header names, cast each to its proper type, collect the records into a list. The achievement is not any one step, it is seeing them as one motion.
The shape you end with, a list of dictionaries, is worth recognizing as more than this lesson's answer. It is the canonical in-memory table, and one of the two common shapes a data-frame library will build its tables from later, with far more speed and far more features. Building it yourself, by hand, with nothing but the language's basics, means that when that library arrives it will not feel like magic, it will feel like a faster version of the loop you already wrote. That is the quiet purpose of doing it manually first: not because you will always parse CSV by hand, but because understanding the manual version is what keeps you in control of the automatic one. JSON, meanwhile, handles the case CSV cannot, nested, hierarchical data, and the json module spares you any hand-parsing there, at the cost of a smaller type set you have to respect.
With this, the section closes and a real capability is in your hands: your programs can now take in data from the outside world, clean it, structure it, and write results back out. That is the boundary between a program that only knows what you told it and one that can learn from data it has never seen. Everything ahead, the analysis, the statistics, the models, assumes data has already made this journey from disk to a clean, typed, in-memory table. You can now make that journey happen, which means you are ready for what comes next.
Cheat Sheet
Part A - Reference Table
| Task | Code | Notes |
|---|---|---|
| Parse a line | [p.strip() for p in line.strip().split(",")] |
strip-split-strip |
| Cast a field | int(x), float(x) |
text → real type |
| Read header | header = f.readline().strip().split(",") |
first line names columns |
| Pair names+values | dict(zip(header, values)) |
one record dict |
| Build table | records.append(dict(zip(header, values))) |
list-of-dicts |
| Access by name | row["label"] |
self-documenting |
| JSON to file | json.dump(obj, f, indent=2) |
needs open file |
| JSON from file | json.load(f) |
needs open file |
| JSON to string | json.dumps(obj) |
returns a string |
| JSON from string | json.loads(text) |
parses a string |
Part B - Compact Code Block
import json
# Manual CSV loader -> typed list-of-dicts.
records = []
with open("data.csv", "r", encoding="utf-8") as f:
# column names
header = f.readline().strip().split(",")
for line in f:
vals = [v.strip() for v in line.strip().split(",")]
raw = dict(zip(header, vals)) # named, all-strings
records.append({ # cast per field
"id": int(raw["id"]),
"label": raw["label"],
"length": float(raw["length"]),
})
# JSON round trip (nested-friendly).
with open("data.json", "w", encoding="utf-8") as f:
json.dump(records, f, indent=2)
with open("data.json", "r", encoding="utf-8") as f:
back = json.load(f) # equals records
# dump/load = files, dumps/loads = strings
# CSV = flat table, JSON = nested data