
Picture this: your team just built a beautiful data pipeline. It pulls customer transaction records from an API, transforms the data, and loads it into a data warehouse for analysis. Everything works perfectly in development — until you hit production, where you're suddenly dealing with 50 million rows per day instead of 50,000. The pipeline grinds to a halt. Queries take forever. Storage costs explode. Your manager is asking questions you don't have answers to.
Nine times out of ten, the culprit isn't the pipeline logic itself. It's the file format. The team saved everything as CSV because "it opens in Excel," and now they're paying the price in processing time, storage bills, and late nights debugging memory errors. Choosing the wrong serialization format is one of the most common and costly mistakes in data engineering — and it's entirely avoidable once you understand what's actually happening under the hood.
By the end of this lesson, you'll have a practical mental model for choosing the right data format for every stage of your pipeline. You'll understand the tradeoffs between human readability, performance, schema enforcement, and compression — and you'll be able to make an informed decision the next time someone asks "should we use JSON or Parquet for this?"
What you'll learn:
Before we compare formats, let's make sure we're speaking the same language.
Serialization is the process of converting data that exists in memory — a Python dictionary, a database row, a list of objects — into a format that can be stored on disk or transmitted over a network. Deserialization is the reverse: reading that stored format back into memory so a program can work with it.
Think of it like packing a suitcase. Your clothes (the data) are organized in your closet (memory). When you travel (move data between systems), you need to fold and pack them into a suitcase (serialize). When you arrive, you unpack (deserialize) back into a usable state. The way you pack — rolled vs. folded, with or without compression bags — determines how much fits, how long it takes to pack and unpack, and whether the clothes arrive wrinkled.
In a data pipeline, serialization happens constantly:
Every one of those steps has a cost in time and storage. Choosing the right format at each stage is how you make a pipeline that scales.
Let's introduce each format through a concrete scenario. Imagine you're building a pipeline for an e-commerce company. You need to move a dataset of customer orders that looks like this:
order_id: 10482
customer_id: C-8821
product: "Wireless Headphones"
quantity: 2
unit_price: 79.99
order_date: 2024-03-15
shipping_address: "412 Elm Street, Austin, TX 78701"
is_premium_customer: true
You have 50 million rows of this. How you store it matters enormously.
JSON (JavaScript Object Notation) is a text-based format that stores data as key-value pairs, arrays, and nested objects. If you've ever worked with a web API, you've seen JSON.
Here's what a few rows of our order data looks like in JSON:
[
{
"order_id": 10482,
"customer_id": "C-8821",
"product": "Wireless Headphones",
"quantity": 2,
"unit_price": 79.99,
"order_date": "2024-03-15",
"shipping_address": "412 Elm Street, Austin, TX 78701",
"is_premium_customer": true
},
{
"order_id": 10483,
"customer_id": "C-3301",
"product": "USB-C Hub",
"quantity": 1,
"unit_price": 49.99,
"order_date": "2024-03-15",
"shipping_address": "88 Oak Avenue, Denver, CO 80203",
"is_premium_customer": false
}
]
Notice that every single record repeats the field names (order_id, customer_id, etc.). At two rows, this is fine. At 50 million rows, you're storing the string "is_premium_customer" fifty million times. That's pure overhead.
JSON's biggest strength: flexibility. It handles nested structures naturally. If one order has a discount_codes field that's an array of strings, and another order doesn't have that field at all, JSON handles both without complaint. This makes it perfect for ingesting data from APIs, where schemas change frequently and records can have variable structure.
JSON's biggest weakness: efficiency. It's verbose (all those repeated keys), it's slow to parse at scale, and it has no built-in compression. Reading 50 million JSON records requires parsing every byte of every key and value.
When to use JSON:
When to avoid JSON:
Tip: A common middle ground is newline-delimited JSON (NDJSON or JSON Lines), where each row is a self-contained JSON object on its own line. This makes it easier to process one record at a time without loading the entire file into memory. Many data tools support
.jsonlfiles for this reason.
CSV (Comma-Separated Values) is about as simple as it gets. Each row is a record, each value is separated by a comma (or sometimes a tab or pipe character), and the first row usually contains column names.
Here's our order data in CSV:
order_id,customer_id,product,quantity,unit_price,order_date,shipping_address,is_premium_customer
10482,C-8821,Wireless Headphones,2,79.99,2024-03-15,"412 Elm Street, Austin, TX 78701",true
10483,C-3301,USB-C Hub,1,49.99,2024-03-15,"88 Oak Avenue, Denver, CO 80203",false
Compared to JSON, CSV is already more efficient — the column names only appear once. But CSV has its own class of problems that trip up even experienced engineers.
CSV's biggest strength: universality. Every tool in the world can read a CSV file. Excel, Google Sheets, Python's csv module, R, SQL databases — all of them handle CSV without any special libraries or configuration. For sharing data with non-technical stakeholders or importing into tools you don't control, CSV is often the only viable option.
CSV's biggest weaknesses:
First, no schema enforcement. CSV has no native concept of data types. The value 79.99 could be a float or a string — the file itself has no way to say which. The value true could be a boolean, or the string "true", or even the number 1. Every tool that reads your CSV makes its own guess, and those guesses don't always match.
Here's a scenario that burns people regularly. Imagine your order_id column contains values like 10482, 10483, and 00012. When a tool reads this as integers, it silently strips the leading zeros: 00012 becomes 12. Your join to another table that uses "00012" as the key now produces no matches, and you spend three hours debugging.
Second, no native support for complex types. What if a customer placed one order but bought three items? In a relational database, you'd use a separate line-items table. In CSV, you either denormalize (repeat the order info for each item) or stuff a list into a single cell, which breaks the format.
Third, commas inside values. That shipping_address field contains a comma ("Austin, TX"). If you're not careful about quoting, your CSV parser will split that into two columns. Most parsers handle quoted fields correctly, but edge cases (commas inside quotes, newlines inside quotes) cause real-world failures.
When to use CSV:
When to avoid CSV:
Warning: Never assume a CSV's schema is stable. When an upstream team adds a new column, deletes a column, or changes a column's data type, your CSV-reading code will silently break or produce wrong results. Always validate CSV schemas at pipeline boundaries.
Now we're getting into the formats built specifically for data engineering at scale. Apache Parquet is a binary, column-oriented format. Let's unpack both of those terms, because they explain why Parquet is so fast.
Binary means the file is not human-readable text. It stores data in a compact, encoded form that machines read efficiently but that looks like gibberish if you open it in a text editor. This eliminates the overhead of text parsing.
Column-oriented (also called "columnar") is the big idea. In a row-oriented format like CSV, all the values for one record are stored together:
[Row 1: order_id=10482, customer_id=C-8821, unit_price=79.99, ...]
[Row 2: order_id=10483, customer_id=C-3301, unit_price=49.99, ...]
[Row 3: ...]
In a column-oriented format, all the values for one column are stored together:
[order_id column: 10482, 10483, 10484, ...]
[customer_id column: C-8821, C-3301, C-9901, ...]
[unit_price column: 79.99, 49.99, 124.99, ...]
Why does this matter? Think about a typical analytical query:
SELECT SUM(unit_price * quantity) as total_revenue
FROM orders
WHERE order_date = '2024-03-15'
This query only needs two columns: unit_price, quantity, and order_date. In a row-oriented format, you must read every byte of every row (including product, shipping_address, and every other field you don't need) just to get to those three values. In a columnar format, you read only the three columns you need and skip everything else. For a 50-million-row dataset with 20 columns, you might be reading 3/20 of the data instead of all of it.
Columnar storage also compresses dramatically better. When all the order_date values for a single day are stored together, they're often identical or very similar. Compression algorithms exploit this repetition to shrink the data by 5x to 10x compared to CSV.
Here's a quick Python example showing how to write and read Parquet using the pandas and pyarrow libraries:
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
# Create a sample DataFrame
orders = pd.DataFrame({
"order_id": [10482, 10483, 10484],
"customer_id": ["C-8821", "C-3301", "C-9901"],
"product": ["Wireless Headphones", "USB-C Hub", "Laptop Stand"],
"quantity": [2, 1, 3],
"unit_price": [79.99, 49.99, 124.99],
"order_date": pd.to_datetime(["2024-03-15", "2024-03-15", "2024-03-16"]),
"is_premium_customer": [True, False, True]
})
# Write to Parquet — types are preserved automatically
orders.to_parquet("orders.parquet", index=False)
# Read back only the columns we need for a revenue query
revenue_data = pd.read_parquet(
"orders.parquet",
columns=["unit_price", "quantity", "order_date"]
)
print(revenue_data.dtypes)
# order_date datetime64[ns]
# unit_price float64
# quantity int64
Notice that when you read the file back, the types are exactly what you put in — datetime64, float64, int64. No guessing, no conversion errors.
Parquet's biggest strength: query performance and storage efficiency. For analytical workloads that scan large datasets and aggregate specific columns, nothing beats Parquet. It's the native format for Spark, the preferred format for data lakes on S3/GCS/Azure Blob, and widely supported by warehouses like BigQuery, Snowflake, and Redshift.
Parquet's biggest weakness: not for row-level operations. Parquet is optimized for reads that scan many rows across few columns. If you need to look up a single record by ID, or if you're inserting/updating individual rows frequently, Parquet is the wrong tool. It's also not human-readable, which makes debugging harder.
When to use Parquet:
When to avoid Parquet:
Apache Avro is a binary, row-oriented format with one killer feature: it bundles the schema directly into the data file, and it handles schema changes (called schema evolution) gracefully.
Here's the problem Avro solves. Your pipeline has been running for two years, writing customer order data in some format. The business team adds a new field: loyalty_points_earned. Now you have:
loyalty_points_earned columnloyalty_points_earned columnloyalty_points_earnedloyalty_points_earnedHow do you handle the transition without breaking everything? In CSV or JSON, this is painful. In Avro, it's built-in.
Avro uses a schema defined in JSON format that travels with the data:
{
"type": "record",
"name": "Order",
"fields": [
{"name": "order_id", "type": "int"},
{"name": "customer_id", "type": "string"},
{"name": "product", "type": "string"},
{"name": "quantity", "type": "int"},
{"name": "unit_price", "type": "float"},
{"name": "order_date", "type": "string"},
{"name": "is_premium_customer", "type": "boolean"},
{
"name": "loyalty_points_earned",
"type": ["null", "int"],
"default": null
}
]
}
That last field uses Avro's union type (["null", "int"]) with a default of null. This means:
null)This backward and forward compatibility is why Avro is the dominant format in streaming pipelines. Apache Kafka, the most widely used message streaming system, pairs almost universally with Avro and a Schema Registry — a centralized service that tracks how schemas have evolved over time and ensures producers and consumers always agree on what the data looks like.
Here's a simplified example of writing Avro with the fastavro Python library:
import fastavro
import io
schema = {
"type": "record",
"name": "Order",
"fields": [
{"name": "order_id", "type": "int"},
{"name": "customer_id", "type": "string"},
{"name": "product", "type": "string"},
{"name": "quantity", "type": "int"},
{"name": "unit_price", "type": "float"},
{"name": "is_premium_customer", "type": "boolean"}
]
}
records = [
{
"order_id": 10482,
"customer_id": "C-8821",
"product": "Wireless Headphones",
"quantity": 2,
"unit_price": 79.99,
"is_premium_customer": True
}
]
# Write to Avro
with open("orders.avro", "wb") as out:
fastavro.writer(out, fastavro.parse_schema(schema), records)
# Read back — schema is embedded in the file, no external reference needed
with open("orders.avro", "rb") as inp:
reader = fastavro.reader(inp)
for record in reader:
print(record)
Avro's biggest strength: schema evolution and streaming compatibility. When your data structure changes over time and multiple systems need to stay synchronized, Avro's contract-based approach prevents silent data corruption.
Avro's biggest weakness: not ideal for analytical queries. Because it's row-oriented like CSV, Avro doesn't give you the column-skipping performance of Parquet. It's also less universally supported in BI tools than Parquet or CSV.
When to use Avro:
When to avoid Avro:
Here's a practical way to think about format selection for each stage of a pipeline:
Stage 1: Ingestion (data arrives from external sources) Use whatever the source gives you. APIs give JSON — accept JSON. Partner sends CSV — accept CSV. Don't fight the source format; transform it later.
Stage 2: Landing zone (raw storage before processing) Store it as-is. For JSON from APIs, consider NDJSON for streaming compatibility. For high-volume event data going through Kafka, use Avro.
Stage 3: Processing (transformation jobs) Your transformation tools (Spark, pandas, dbt) usually have format preferences. For batch processing, convert to Parquet early — you'll pay a one-time conversion cost and save it back on every subsequent read and transformation.
Stage 4: Serving layer (warehouse, data mart, ML features) Parquet for analytics. If the downstream consumer is a non-technical stakeholder or a legacy BI tool that requires flat files, produce a CSV as a final export, but keep Parquet as the source of truth.
| Criterion | JSON | CSV | Parquet | Avro |
|---|---|---|---|---|
| Human-readable | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| Nested structures | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes |
| Schema enforcement | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
| Schema evolution | ❌ No | ❌ No | Limited | ✅ Yes |
| Analytical performance | ❌ Poor | ❌ Poor | ✅ Excellent | ❌ Poor |
| Streaming support | ✅ Yes | ✅ Yes | ❌ No | ✅ Excellent |
| Universal tool support | ✅ Yes | ✅ Yes | ✅ Good | ⚠️ Limited |
| Storage efficiency | ❌ Poor | ❌ Poor | ✅ Excellent | ✅ Good |
Work through these steps on your local machine to build intuition through direct comparison.
Setup: Install the required libraries with pip install pandas pyarrow fastavro
Step 1: Create a Python script that generates a realistic dataset of 100,000 fake order records using the order_id, customer_id, unit_price, quantity, and order_date fields.
Step 2: Save the dataset in three formats: CSV, JSON (as a list), and Parquet. Use Python's time module to measure how long each write takes.
Step 3: Check the file sizes on disk using os.path.getsize() for each file. You should see Parquet is dramatically smaller than CSV or JSON.
Step 4: Write a read operation for each format that loads only the unit_price and quantity columns and computes total revenue. Time each read. For Parquet, use the columns=["unit_price", "quantity"] parameter in pd.read_parquet(). For CSV, use usecols=["unit_price", "quantity"] in pd.read_csv().
What to observe: Parquet reads should be fastest and use the least memory. CSV and JSON write times should be faster for small datasets but degrade at scale. File sizes will likely rank: Parquet << CSV < JSON.
Bonus challenge: Add a new column called discount_applied to the Parquet file and think through: what would you need to do to backfill that column for the existing file? What would Avro's schema evolution have done differently?
Mistake 1: Using CSV for everything because it's familiar The cost of CSV's simplicity is paid later in debugging, type errors, and slow queries. Start using Parquet for any dataset over a few hundred thousand rows. The one-time learning curve pays off immediately.
Mistake 2: Storing JSON from APIs without extracting nested fields JSON nesting is powerful, but if you store raw nested JSON in a data lake without flattening it, every downstream query has to deal with the nesting. Flatten nested structures during ingestion or early transformation, then store as Parquet.
Mistake 3: Assuming CSVs will parse consistently across tools
A CSV that loads perfectly in Python's pandas might silently mangle types when loaded into a SQL database or a BI tool. Always define explicit types when reading CSVs in code, and never rely on auto-detection for production pipelines.
# Don't let pandas guess — explicitly define types
orders = pd.read_csv(
"orders.csv",
dtype={
"order_id": str, # Preserve leading zeros
"unit_price": float,
"quantity": int,
"is_premium_customer": bool
},
parse_dates=["order_date"]
)
Mistake 4: Forgetting to partition Parquet files
A single Parquet file with 50 million rows is better than a single CSV with 50 million rows, but it's not optimal. Parquet really shines when you partition the data — organize it into folders by a column like order_date. A query for a single day only reads that day's folder, not the whole dataset.
# Partition by year and month for date-range queries
orders.to_parquet(
"orders_partitioned/",
partition_cols=["order_date"],
index=False
)
# Creates: orders_partitioned/order_date=2024-03-15/part-0.parquet
Mistake 5: Using Avro for ad-hoc analytical queries Avro is for pipelines and streaming, not for data scientists running exploratory queries. If your data scientists are complaining that their Spark or pandas jobs are slow, check whether they're reading Avro files. Convert to Parquet for the analytical layer.
You now have a solid foundation for one of the most practically impactful decisions in pipeline design. Let's recap the core mental model:
The highest-leverage takeaway: most pipelines should be ingesting in JSON or CSV (because that's what sources provide), converting to Parquet as early as possible in the transformation layer, and using Avro if any part of the pipeline goes through a streaming system.
Next steps in this learning path:
Learning Path: Data Pipeline Fundamentals