Working with CSV and JSON
The csv and json modules in depth — DictReader/DictWriter, quoting and delimiter edge cases, JSON type mapping, nested data, and a full worked pipeline.
CSV Looks Simple Until It Isn't
A CSV (comma-separated values) file looks simple enough that a first instinct is often to parse it by hand — read each line, call .split(","), and move on. This works for the simplest possible files and breaks the moment real-world data shows up.
line = "Acme Inc,\"Springfield, IL\",1200.50"
fields = line.split(",")
print(fields)
# ['Acme Inc', '"Springfield', ' IL"', '1200.50']
# WRONG — four fields instead of three. The comma INSIDE "Springfield, IL"
# was treated as a field separator, because .split(",") has no concept
# of quoting at all.Real CSV data routinely contains commas inside a field (an address, a company name with a comma, free-text notes), and sometimes even newlines inside a quoted field (a multi-line comment exported from a form). A correct CSV parser has to understand quoting rules — which is exactly why Python ships a dedicated csv module rather than expecting you to reimplement this logic yourself.
csv module, covered next, every time.The Low-Level Interface — Rows as Lists
The csv module's most basic tools, csv.reader and csv.writer, wrap a file object and handle the quoting rules correctly, giving you each row as a plain Python list of strings.
import csv
with open("orders.csv", newline="", encoding="utf-8") as f:
reader = csv.reader(f)
header = next(reader) # the first row — usually column names
for row in reader:
print(row) # each row is a list, e.g. ['1001', 'Acme Inc', '1200.50']
# header -> ['order_id', 'customer', 'total']import csv
rows = [
["order_id", "customer", "total"],
["1001", "Acme Inc", "1200.50"],
["1002", "Springfield Widgets", "89.99"],
]
with open("export.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerows(rows) # writes every row, correctly quoting where needed
# or writer.writerow(row) for a single row at a timeNotice that csv.writer handles quoting automatically — if a field you pass contains a comma or a newline, it wraps that field in quotes for you in the output, exactly reversing the problem shown in Part 01.
The Preferred, Idiomatic Interface — Rows as Dictionaries
csv.reader gives you each row as a list — which means accessing a specific column requires remembering its numeric position (row[2] for the total, say), a fragile approach that breaks silently if the column order ever changes. csv.DictReader fixes this by using the header row to key each row into a dictionary instead.
import csv
with open("orders.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
print(row["customer"], row["total"])
# row is a dict: {'order_id': '1001', 'customer': 'Acme Inc', 'total': '1200.50'}
# The header row is consumed automatically — DictReader uses it to build
# each row's keys, and you never see it as a separate "data" row.row["customer"] is self-documenting and survives a reordered or extended set of columns without breaking, while row[1] silently returns the wrong value the moment a column is inserted anywhere before it. Every value from DictReader is still a string, exactly as with plain csv.reader — CSV has no concept of types at all, covered further in Part 06.import csv
rows = [
{"order_id": "1001", "customer": "Acme Inc", "total": "1200.50"},
{"order_id": "1002", "customer": "Springfield Widgets", "total": "89.99"},
]
with open("export.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["order_id", "customer", "total"])
writer.writeheader() # DictWriter does NOT write the header automatically —
# you must call this explicitly, exactly once
writer.writerows(rows)DictWriter requires fieldnames up front — the exact list and order of columns to write — since a Python dict doesn't inherently guarantee the column order you want in the output file. Every dict you write must contain exactly those keys, or DictWriter raises a ValueError rather than silently dropping or misplacing data.
What the csv Module Handles For You — and What You Configure
"CSV" is not one rigidly standardised format — real files vary in their delimiter, their quote character, and how they escape a quote character that appears inside a quoted field. The csv module exposes all of this as configuration rather than assuming one fixed convention.
import csv
with open("customers.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["name", "address", "notes"])
writer.writerow(["Acme Inc", "123 Main St, Springfield, IL", "Called twice;\nfollow up Friday"])
# The resulting file correctly quotes both problem fields:
# name,address,notes
# Acme Inc,"123 Main St, Springfield, IL","Called twice;
# follow up Friday"
#
# Reading it back with csv.reader correctly reconstructs both fields as
# SINGLE values, embedded comma and embedded newline intact.Custom delimiters — tab-separated values and beyond
Not every "comma-separated" export actually uses a comma — tab-separated files (often .tsv) and semicolon-delimited exports (common from European locales, where a comma is the decimal separator) are both common in real data. The csv module handles either with a single argument.
import csv
with open("export.tsv", newline="", encoding="utf-8") as f:
reader = csv.reader(f, delimiter="\t")
for row in reader:
print(row)Dialects — bundling a set of formatting rules together
When several arguments need to travel together consistently (delimiter, quote character, line terminator), the module lets you register a named dialect once and reuse it, rather than repeating the same keyword arguments at every call site.
import csv
csv.register_dialect("pipe_delimited", delimiter="|", quotechar='"')
with open("legacy_export.txt", newline="", encoding="utf-8") as f:
reader = csv.reader(f, dialect="pipe_delimited")
for row in reader:
print(row)json.load, json.loads, json.dump, json.dumps
JSON (JavaScript Object Notation) is the dominant format for structured data exchanged between services — API responses, configuration files, and message payloads are overwhelmingly JSON. Python's built-in json module converts between JSON text and native Python objects (dicts, lists, strings, numbers, booleans, and None) in both directions.
The module has four core functions, and the naming pattern is worth memorising: the ones ending in s work with strings already in memory; the ones without it work directly with an open file object.
json.loads(text) # parse a JSON STRING already in memory -> Python object
json.load(file_obj) # parse JSON directly FROM AN OPEN FILE -> Python object
json.dumps(obj) # serialize a Python object -> a JSON STRING
json.dump(obj, file_obj) # serialize a Python object directly INTO AN OPEN FILEimport json
with open("config.json", encoding="utf-8") as f:
config = json.load(f)
print(config["database"]["host"]) # a nested dict, accessed like any Python dictimport json
settings = {"theme": "dark", "notifications": True, "max_retries": 3}
with open("settings.json", "w", encoding="utf-8") as f:
json.dump(settings, f)import json
response_text = '{"status": "ok", "count": 3}'
data = json.loads(response_text) # note the trailing "s" — parsing a string
print(data["count"]) # 3, an actual Python int
payload = json.dumps({"user": "maria", "active": True})
print(payload) # '{"user": "maria", "active": true}' — a strThe Mapping — and Where It Genuinely Doesn't Line Up
JSON has its own small set of types, and the json module maps each one to the closest matching Python type automatically. Most of the mapping is exactly what you would expect.
JSON Python (after json.load / json.loads)
------ ------
object dict
array list
string str
number (int) int
number (float) float
true / false bool (True / False)
null NoneThe reverse direction (json.dump / json.dumps) maps Python types back to JSON using the same table — but a few Python types have no direct JSON equivalent, and this is where real bugs show up.
tuple — silently becomes a JSON array
import json
data = {"point": (3, 4)} # a tuple
text = json.dumps(data)
print(text) # '{"point": [3, 4]}' — now a JSON array
restored = json.loads(text)
print(restored["point"]) # [3, 4] — a LIST, not a tuple. The tuple is gone for good.set — has no JSON equivalent at all, and raises an error
import json
data = {"tags": {"python", "backend", "api"}} # a set
json.dumps(data)
# TypeError: Object of type set is not JSON serializable
# A set must be explicitly converted to a list first if it needs to round-trip:
data = {"tags": list({"python", "backend", "api"})}
json.dumps(data) # works — but the result, once read back, is a list, not a setFloat precision — the same imprecision from Module 02, now serialized
JSON numbers are text in the file, parsed into Python float objects on load — which means the exact same IEEE 754 floating-point imprecision covered in the Variables & Data Types module carries straight through. A value serialized as 19.1 and read back can come back as something like 19.099999999999998, for the same underlying reason 0.1 + 0.2 doesn't equal 0.3 exactly.
"19.99") and convert it to decimal.Decimal explicitly on the Python side after loading — exactly the same underlying lesson from the Variables & Data Types module, now showing up again at the JSON boundary.Working With Real, Deeply Nested JSON
Real-world JSON is rarely flat — API responses commonly nest dicts inside dicts, lists of dicts, and dicts containing lists, exactly the shapes covered in the Nested Data Structures module. json.load reconstructs the full nested structure automatically; the work is in navigating it correctly afterward.
import json
response_text = '''
{
"customer": {
"name": "Acme Inc",
"contacts": [
{"type": "billing", "email": "billing@acme.com"},
{"type": "support", "email": "support@acme.com"}
]
},
"orders": [
{"id": 1001, "total": 1200.50, "items": ["widget", "gadget"]},
{"id": 1002, "total": 89.99, "items": ["gizmo"]}
]
}
'''
data = json.loads(response_text)
print(data["customer"]["name"]) # "Acme Inc"
print(data["customer"]["contacts"][0]["email"]) # "billing@acme.com"
print(data["orders"][1]["items"]) # ["gizmo"]
order_total = sum(order["total"] for order in data["orders"])
print(order_total) # 1290.49null. data.get("discount", 0) returns a safe default instead of raising a KeyError, exactly the same .get() pattern from the Dictionaries module, now applied specifically to parsed JSON.Pretty-Printing, and a Full CSV-to-JSON Transformation
json.dumps() and json.dump() both accept an indent argument that formats the output with readable line breaks and indentation — genuinely useful any time a human, not just another program, needs to read the output.
import json
data = {"customer": "Acme Inc", "total": 1200.50, "items": ["widget", "gadget"]}
print(json.dumps(data))
# {"customer": "Acme Inc", "total": 1200.5, "items": ["widget", "gadget"]} — one line
print(json.dumps(data, indent=2))
# {
# "customer": "Acme Inc",
# "total": 1200.5,
# "items": [
# "widget",
# "gadget"
# ]
# }Now a complete, realistic example: reading a CSV of individual order line items and writing out a JSON summary grouped by customer — combining everything from this module, including DictReader, nested structure-building, and Decimal-aware totals.
order_id,customer,item,quantity,unit_price
1001,Acme Inc,widget,4,12.50
1001,Acme Inc,gadget,1,45.00
1002,Springfield Widgets,gizmo,2,9.99
1003,Acme Inc,widget,10,12.50import csv
import json
from decimal import Decimal
from collections import defaultdict
summary = defaultdict(lambda: {"order_count": set(), "total": Decimal("0")})
with open("orders.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
customer = row["customer"]
line_total = Decimal(row["unit_price"]) * int(row["quantity"])
summary[customer]["order_count"].add(row["order_id"])
summary[customer]["total"] += line_total
# Convert to a plain, JSON-serializable structure —
# note the explicit conversions: set -> len(), Decimal -> str()
output = {
customer: {
"order_count": len(stats["order_count"]),
"total": str(stats["total"]), # serialize money as a STRING, not a float — Part 06
}
for customer, stats in summary.items()
}
with open("customer_summary.json", "w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
# customer_summary.json:
# {
# "Acme Inc": {
# "order_count": 2,
# "total": "182.50"
# },
# "Springfield Widgets": {
# "order_count": 1,
# "total": "19.98"
# }
# }Every design decision in that pipeline traces back to earlier parts of this module: DictReader for column-name access (Part 03), Decimal instead of float for money, and explicitly converting the set and the Decimal to JSON-safe types before serializing (Part 06), rather than letting json.dump fail — or worse, silently misrepresent the data.
The Invoice Export That Broke Accounting — Austin, TX
An invoicing platform exports a nightly CSV of billing records for a client's accounting software to import. A support ticket comes in: several rows are importing with the wrong data entirely — an address ends up in the "amount" column, and totals downstream are off by thousands of dollars.
What the engineer finds
The export code had been written by hand, joining fields with ",".join(fields) rather than using csv.writer. One client's billing address happened to contain a comma — "400 Congress Ave, Suite 200" — and, with no quoting logic at all, that single field split into two columns on import, shifting every field after it one position to the right for that row.
# Before — looks reasonable, has no concept of quoting
def export_row(record):
fields = [record.id, record.customer, record.address, str(record.amount)]
return ",".join(fields) + "\n"
# record.address = "400 Congress Ave, Suite 200"
# -> "1001,Acme Inc,400 Congress Ave, Suite 200,1200.50\n"
# Five comma-separated values where the importer expects exactly four —
# every downstream column shifts by one.import csv
def export_rows(records, f):
writer = csv.writer(f)
writer.writerow(["id", "customer", "address", "amount"])
for record in records:
writer.writerow([record.id, record.customer, record.address, record.amount])
# record.address containing a comma is now automatically wrapped in quotes:
# 1001,Acme Inc,"400 Congress Ave, Suite 200",1200.50
# — exactly one field, correctly reconstructed by any real CSV reader.The bug had shipped for months without being noticed, because most customer addresses didn't happen to contain a comma — exactly the trap described in Part 01. It only surfaced with a specific client's specific address format, and by then several weeks of exports needed to be manually re-processed. The team's follow-up wasn't just the fix above; it was a rule, now enforced in code review, that no file export is written with manual string joining — every CSV export goes through the csv module, no exceptions.
Four Misconceptions About CSV and JSON
5 Interview Questions — With Complete Answers
CSV and JSON Mistakes Beginners Make Constantly
Errors You Will Hit With CSV and JSON — And Exactly Why
🎯 Key Takeaways
- ✓Never parse CSV with .split(",") by hand — embedded commas and newlines inside quoted fields will silently misparse. Use the csv module every time.
- ✓Always open files for csv.reader/csv.writer with newline="" — the csv module needs to manage newline handling itself for quoting to work correctly.
- ✓Prefer csv.DictReader and csv.DictWriter over the plain reader/writer — accessing columns by name is self-documenting and resilient to column reordering.
- ✓Every CSV value is a string, with no exceptions — convert explicitly with int(), float(), or Decimal() before doing arithmetic.
- ✓json.loads/json.dumps work with strings already in memory; json.load/json.dump work directly with an open file object.
- ✓Most Python types map cleanly to and from JSON — but tuples silently become lists, and sets raise a TypeError and must be converted to a list explicitly first.
- ✓JSON floats carry the same IEEE 754 precision issues as any Python float — serialize money as a string and convert to decimal.Decimal explicitly, never round-trip it as a raw float.
- ✓Use .get("key", default) rather than direct key access when parsing real-world JSON, since optional fields are frequently omitted rather than sent as null.
- ✓indent=2 (or similar) on json.dumps()/json.dump() produces human-readable output — omit it for compact machine-to-machine payloads.
What comes next
Module 17 covers exception handling in full — try/except/else/finally, the exception hierarchy, custom exceptions, and how to make programs fail safely instead of silently, which matters immediately for the file and data parsing work covered in this module.
Module 17 → Exception HandlingDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.