Skip to content

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.

json_operations.py
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(",").

csv_operations.py
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).

serialization_formats.py
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\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="".
  • 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 custom JSONEncoder.
  • 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/loads for strings; json.dump/load for files. Use indent=2 for readability, ensure_ascii=False for Unicode.
  • JSON doesn't support datetime, set, or Decimal — convert to strings/lists/floats explicitly.
  • Always open CSV files with newline=""; use DictReader/DictWriter, never str.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.