Data Formats: JSON, CSV & Serialization¶
What this lesson gives you
Moving data between systems means serializing it. JSON and CSV are the lingua franca of APIs and data exchange.
Estimated time: 20 min read · Part: Files, Errors & the Outside World
Learning objectives
- Serialize and deserialize JSON fluently, including custom type handling
- Read and write CSV files correctly with
DictReader/DictWriter, handling edge cases - Understand the type mapping between Python and JSON
- Know when to use pickle, and critically, when never to use it
- Choose the right serialization format for the use case: JSON vs CSV vs pickle vs msgpack
Serialization converts in-memory objects to a storable or transmittable format; deserialization reconstructs them. Every time data crosses a system boundary — written to disk, sent over a network, passed to another process — it must be serialized. The choice of format carries implications for human-readability, performance, interoperability, and security.
JSON: The Web's Universal Format¶
JSON (JavaScript Object Notation) is the lingua franca of web APIs. Python's json module maps Python objects to JSON equivalently — almost. The critical exceptions are datetime, set, Decimal, and custom objects, none of which are JSON-native. You must convert them explicitly, or write a custom encoder.
import json
from pathlib import Path
from datetime import datetime
from decimal import Decimal
# Basic serialization
record = {
"user": "ada",
"scores": [90, 85, 92],
"active": True,
"metadata": None,
}
text = json.dumps(record) # compact: no whitespace
text = json.dumps(record, indent=2) # pretty-printed
text = json.dumps(record, indent=2, sort_keys=True) # sorted keys
# Deserialization
obj = json.loads(text) # string → Python object
obj = json.loads(Path("data.json").read_text(encoding="utf-8"))
# File I/O — preferred over read_text + loads
with open("data.json", "w", encoding="utf-8") as f:
json.dump(record, f, indent=2, ensure_ascii=False) # non-ASCII preserved
with open("data.json", encoding="utf-8") as f:
data = json.load(f)
# Custom encoder for non-JSON-native types
class AppEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat() # "2026-06-27T14:32:01"
if isinstance(obj, Decimal):
return float(obj) # or str for exact precision
if isinstance(obj, set):
return sorted(list(obj)) # sets → sorted lists
return super().default(obj)
complex_obj = {
"created_at": datetime(2026, 6, 27, 14, 32, 1),
"price": Decimal("19.99"),
"tags": {"python", "api"},
}
json.dumps(complex_obj, cls=AppEncoder, indent=2)
# {
# "created_at": "2026-06-27T14:32:01",
# "price": 19.99,
# "tags": ["api", "python"]
# }
| Python type | JSON type | Notes |
|---|---|---|
dict | object {} | Keys must be strings in JSON |
list, tuple | array [] | Tuples become arrays; decode as lists |
str | string | Unicode preserved with ensure_ascii=False |
int, float | number | JSON has no int/float distinction |
True / False | true / false | Case difference: Python capitalizes |
None | null | Round-trips cleanly |
datetime | Not supported | Encode as ISO string; decode manually |
Decimal | Not supported | Encode as float or string |
set | Not supported | Encode as sorted list |
bytes | Not supported | Encode as base64 string |
Use ensure_ascii=False for international data
By default json.dumps() escapes all non-ASCII characters to \uXXXX sequences. So "café" becomes "café" — technically valid JSON but unreadable in logs and larger than necessary. Passing ensure_ascii=False preserves Unicode characters as-is. Always use it for data that may contain international characters, and always pair with encoding="utf-8" on your file writes.
CSV: Tabular Data Exchange¶
CSV (Comma-Separated Values) is the universal format for tabular data. Despite its apparent simplicity, CSV has many edge cases: values containing commas, newlines, or quotes; different delimiters (tabs, pipes); different line endings (CRLF vs LF); header rows; encoding issues. Python's csv module handles all of these correctly — never parse CSV with str.split(",").
import csv
from pathlib import Path
# ── Reading with DictReader ───────────────────────────────────────────────────
# newline="" is required on all platforms to prevent double-newline on Windows
with open("customers.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
# reader.fieldnames is populated from the header row
for row in reader:
print(row["name"], row["email"], row["spend"])
# All values are strings — convert types yourself
# Handling missing columns gracefully
with open("customers.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
records = []
for row in reader:
records.append({
"name": row["name"].strip(),
"email": row["email"].lower().strip(),
"spend": float(row.get("spend", "0") or "0"),
})
# ── Writing with DictWriter ───────────────────────────────────────────────────
fieldnames = ["id", "name", "email", "spend"]
with open("output.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerow({"id": 1, "name": "Ada", "email": "ada@x.io", "spend": 299.0})
writer.writerow({"id": 2, "name": "Bob", "email": "bob@y.io", "spend": 85.0})
writer.writerows([ # batch write
{"id": 3, "name": "Carol", "email": "c@z.io", "spend": 412.0},
{"id": 4, "name": "Dave", "email": "d@w.io", "spend": 55.0},
])
# ── Tab-separated and custom delimiters ──────────────────────────────────────
with open("data.tsv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter="\t") # TSV
for row in reader:
...
# ── Reading from in-memory string (useful for testing) ───────────────────────
import io
csv_text = "name,score\nAda,95\nBob,82\n"
reader = csv.DictReader(io.StringIO(csv_text))
rows = list(reader)
# [{'name': 'Ada', 'score': '95'}, {'name': 'Bob', 'score': '82'}]
Always use newline='' when opening CSV files
On Windows, Python's universal newline translation (enabled by default) converts \r\n to \n on read and \n to \r\n on write. The csv module does its own newline handling. Combining both produces double \r characters, breaking the CSV format. The fix: always pass newline="" when opening files for csv. This is documented in the csv module docs and is a very common bug in production CSV code.
Pickle: Powerful but Dangerous¶
pickle serializes arbitrary Python objects — including custom classes, functions, and complex object graphs. It's fast and handles things JSON cannot. The critical warning: unpickling executes code. A malicious pickle file can run arbitrary Python on your machine. Never deserialize pickle data from an untrusted source (network, user upload, external API).
import pickle, json
from pathlib import Path
# pickle: full Python objects — TRUSTED DATA ONLY
data = {"model": some_sklearn_model, "created": datetime.now()}
Path("model.pkl").write_bytes(pickle.dumps(data))
loaded = pickle.loads(Path("model.pkl").read_bytes()) # ONLY safe for your own files
# NEVER do this with external data:
# user_data = request.body
# obj = pickle.loads(user_data) # REMOTE CODE EXECUTION VULNERABILITY
# For ML models specifically: use joblib (wrapper around pickle with compression)
# import joblib; joblib.dump(model, "model.joblib"); joblib.load("model.joblib")
# For configs and API data: JSON is always the right answer
# For dataclasses → dict for JSON: use dataclasses.asdict()
from dataclasses import dataclass, asdict
@dataclass
class Config:
host: str
port: int
debug: bool
cfg = Config(host="localhost", port=8080, debug=True)
json_str = json.dumps(asdict(cfg), indent=2)
# {"host": "localhost", "port": 8080, "debug": true}
# Restore: pass dict fields back as kwargs
cfg2 = Config(**json.loads(json_str))
| Format | Human-readable | Python-native types | Performance | Security | Use for |
|---|---|---|---|---|---|
| JSON | Yes | Subset only | Moderate | Safe | APIs, configs, logs |
| CSV | Yes | Strings only | Moderate | Safe | Tabular data, spreadsheets |
| pickle | No | All Python objects | Fast | Dangerous | Trusted Python-to-Python only |
| TOML | Yes | Subset | Moderate | Safe | Config files (pyproject.toml) |
| msgpack | No | Subset | Very fast | Safe | High-volume inter-service |
| Parquet | No | Typed columns | Very fast | Safe | Large analytic datasets |
Consulting lens: choosing the right format
The choice of serialization format is an architectural decision with long-term implications. JSON is safe, interoperable, and human-debuggable — the right default for any cross-language or external-facing boundary. CSV remains necessary for stakeholders who need data in spreadsheets. Pickle is acceptable for ML model artifacts stored in your own infrastructure — but document the Python version and library versions, because pickle is not stable across major Python releases. For high-throughput microservices, msgpack or Protocol Buffers offer meaningful performance gains over JSON. Pick the simplest format that meets your requirements — not the most powerful one.
Knowledge check
SecurityA teammate uses pickle.loads() on data received from an external HTTP API request. What is the critical risk?
- Pickle is just slower than JSON; there's no real security concern.
- Unpickling untrusted data can execute arbitrary Python code — a Remote Code Execution (RCE) vulnerability. Use JSON for any data crossing a trust boundary.
- Pickle can only store strings, so the data will be garbled.
- Nothing — pickle validates all input by design.
Answer
Unpickling untrusted data can execute arbitrary Python code — a Remote Code Execution (RCE) vulnerability. Use JSON for any data crossing a trust boundary.
pickle reconstructs arbitrary Python objects by calling constructors and executing code during deserialization. A crafted malicious pickle payload can invoke os.system() or any other callable. This is a well-known, exploited vulnerability. Never unpickle data you didn't create yourself. Use JSON for any data that crosses a trust boundary.
Knowledge check
GotchaYou open a CSV file with open("data.csv") (no newline="" arg) on Windows and the output has extra blank lines between rows. Why?
- The CSV file is corrupted.
- Python's universal newline mode translates
\r\nto\n; the csv writer also writes\r\n; the combination produces\r\r\n— a blank line between each row. Fix: always usenewline="". - DictWriter always adds blank lines for readability.
- Python 3's csv module doesn't support Windows line endings.
Answer
Python's universal newline mode translates \r\n to \n; the csv writer also writes \r\n; the combination produces \r\r\n — a blank line between each row. Fix: always use newline="".
Python's text mode (default) translates newlines on Windows: reading converts \r\n → \n ; writing converts \n → \r\n . The csv module writes its own \r\n line endings. When text-mode translation is also active, \r\n gets translated to \r\r\n , creating a blank line between every data row. The fix is always to open CSV files with newline="" to disable Python's translation and let the csv module manage newlines.
Knowledge check
Concept CheckYou have a Python datetime object and need to include it in a JSON response. What is the standard approach?
- Pass it directly to
json.dumps()— Python converts it automatically. - Convert it to an ISO 8601 string (
dt.isoformat()) before or during serialization — either manually or via a customJSONEncoder. - Use
json.dumps(str(dt))— Python's str() produces valid JSON dates. - Use pickle instead — JSON doesn't support dates.
Answer
Convert it to an ISO 8601 string (dt.isoformat()) before or during serialization — either manually or via a custom JSONEncoder.
json.dumps() raises TypeError: Object of type datetime is not JSON serializable for bare datetime objects. The idiomatic solution is to call dt.isoformat() which returns an ISO 8601 string like "2026-06-27T14:32:01" — a widely understood format that's round-trippable with datetime.fromisoformat() . For systematic handling, write a custom JSONEncoder subclass that overrides default() .
Exercise: ETL Pipeline — JSON API to CSV Report
Write a function export_summary(api_response_json: str, output_path: str) that: parses a JSON string containing a list of order records (each with order_id, customer_name, amount, created_at as ISO timestamp, and items as a list), and writes a CSV with columns: order_id, customer_name, item_count, amount, date (YYYY-MM-DD only). Handle missing fields gracefully with defaults.
Show solution
import json
import csv
from datetime import datetime
from pathlib import Path
def export_summary(api_response_json: str, output_path: str) -> int:
"""
Parse JSON order data and write summary CSV.
Returns the number of rows written.
"""
orders = json.loads(api_response_json)
if not isinstance(orders, list):
orders = orders.get("orders", []) # handle wrapped responses
fieldnames = ["order_id", "customer_name", "item_count", "amount", "date"]
rows_written = 0
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for order in orders:
# Parse ISO timestamp → date string
raw_ts = order.get("created_at", "")
try:
dt = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
date = dt.strftime("%Y-%m-%d")
except (ValueError, AttributeError):
date = "unknown"
writer.writerow({
"order_id": order.get("order_id", ""),
"customer_name": order.get("customer_name", "").strip(),
"item_count": len(order.get("items", [])),
"amount": f"{order.get('amount', 0.0):.2f}",
"date": date,
})
rows_written += 1
return rows_written
# Test
sample_json = json.dumps([
{"order_id": "ORD-1", "customer_name": "Ada Lovelace",
"amount": 299.0, "created_at": "2026-06-27T14:32:01Z",
"items": ["pen", "notebook", "ruler"]},
{"order_id": "ORD-2", "customer_name": "Bob Martin",
"amount": 85.0, "created_at": "2026-06-27T15:10:00Z",
"items": ["book"]},
])
n = export_summary(sample_json, "orders_summary.csv")
print(f"Wrote {n} rows")
# Verify output
with open("orders_summary.csv", encoding="utf-8") as f:
print(f.read())
# order_id,customer_name,item_count,amount,date
# ORD-1,Ada Lovelace,3,299.00,2026-06-27
# ORD-2,Bob Martin,1,85.00,2026-06-27
Key takeaways
json.dumps/loadsfor strings;json.dump/loadfor files. Useindent=2for readability,ensure_ascii=Falsefor Unicode.- JSON doesn't support
datetime,set, orDecimal— convert to strings/lists/floats explicitly. - Always open CSV files with
newline=""; useDictReader/DictWriter, neverstr.split(","). - Never unpickle untrusted data — it's a remote code execution vulnerability.
- Format selection: JSON for APIs and configs; CSV for tabular/spreadsheet data; pickle only for trusted Python-to-Python artifacts.