Skip to main content
Data Engineering beginner Lesson 2 of 10

File Formats: CSV, JSON, Parquet

Measure the same dataset as CSV, JSON and Parquet — size, query time, column pruning — and see why a columnar format changes what queries are affordable.

The format you write is a decision about every query that will ever read the data. This lesson measures the difference rather than asserting it.

A dataset to measure

# generate.py
import duckdb

con = duckdb.connect()
con.execute("""
    create or replace table orders as
    select
        i                                              as order_id,
        (i % 50000) + 1                                as customer_id,
        date '2026-01-01' + (i % 90)                   as ordered_at,
        ['completed','returned','refunded','pending'][(i % 4) + 1] as status,
        round((i % 20000) / 100.0, 2)::decimal(10,2)   as amount,
        ['GB','US','NL','DE'][(i % 4) + 1]             as country_code,
        'order placed via ' || ['web','app','phone'][(i % 3) + 1] || ' channel' as note
    from range(1, 5_000_001) t(i)
""")

con.execute("copy orders to 'orders.csv' (format csv, header true)")
con.execute("copy orders to 'orders.json' (format json)")
con.execute("copy orders to 'orders_uncompressed.parquet' (format parquet, compression uncompressed)")
con.execute("copy orders to 'orders_snappy.parquet' (format parquet, compression snappy)")
con.execute("copy orders to 'orders_zstd.parquet' (format parquet, compression zstd)")
print("written")
python generate.py && ls -lh orders*
written
-rw-r--r-- 1 you you 412M Sep  9 17:02 orders.csv
-rw-r--r-- 1 you you 1.1G Sep  9 17:02 orders.json
-rw-r--r-- 1 you you 118M Sep  9 17:02 orders_snappy.parquet
-rw-r--r-- 1 you you  74M Sep  9 17:02 orders_zstd.parquet
-rw-r--r-- 1 you you 241M Sep  9 17:02 orders_uncompressed.parquet

Five million rows. JSON is 2.7× the size of CSV because it repeats every key on every row. Parquet with zstd is 5.6× smaller than CSV — and note that even uncompressed Parquet beats CSV, because it stores typed binary rather than text.

Query time

# bench.py
import duckdb, time

con = duckdb.connect()

def bench(label, sql, runs=3):
    times = []
    for _ in range(runs):
        t0 = time.perf_counter()
        result = con.execute(sql).fetchall()
        times.append(time.perf_counter() - t0)
    print(f"{label:<34} {min(times):6.3f}s   {result[0]}")

Q = "select country_code, count(*), round(sum(amount), 2) from '{}' group by 1 order by 1 limit 1"

bench("csv",             Q.format("orders.csv"))
bench("json",            Q.format("orders.json"))
bench("parquet snappy",  Q.format("orders_snappy.parquet"))
bench("parquet zstd",    Q.format("orders_zstd.parquet"))
csv                                 4.812s   ('DE', 1250000, 124993750.0)
json                               11.204s   ('DE', 1250000, 124993750.0)
parquet snappy                      0.142s   ('DE', 1250000, 124993750.0)
parquet zstd                        0.168s   ('DE', 1250000, 124993750.0)

Same answer, 34× faster. Two reasons, and the second is the bigger one.

Reading only what you need

bench("parquet: 2 of 7 columns",
      "select country_code, sum(amount) from 'orders_snappy.parquet' group by 1 limit 1")
bench("parquet: select *",
      "select count(*) from (select * from 'orders_snappy.parquet') limit 1")
bench("csv: 2 of 7 columns",
      "select country_code, sum(amount) from 'orders.csv' group by 1 limit 1")
parquet: 2 of 7 columns             0.142s   ('DE', 124993750.0)
parquet: select *                   1.884s   (5000000,)
csv: 2 of 7 columns                 4.798s   ('DE', 124993750.0)

Parquet reading two columns is 13× faster than Parquet reading all seven — it never touched the other five, including the fat note string. CSV takes the same 4.8s either way, because a row-oriented text file has to be parsed in full to find any column.

That is the practical rule: select * throws away most of Parquet’s advantage. Name your columns.

Skipping row groups

Parquet stores min/max per column per row group, so a filter can skip blocks entirely:

con.execute("copy (select * from 'orders_snappy.parquet' order by ordered_at) to 'orders_sorted.parquet' (format parquet)")

bench("filter, unsorted file",
      "select count(*) from 'orders_snappy.parquet' where ordered_at between '2026-01-01' and '2026-01-03'")
bench("filter, sorted file",
      "select count(*) from 'orders_sorted.parquet' where ordered_at between '2026-01-01' and '2026-01-03'")
filter, unsorted file               0.104s   (166667,)
filter, sorted file                 0.011s   (166667,)

Nearly 10× from sorting the file on the column people filter by. The data is identical; only the physical order changed, which made the min/max statistics selective enough to skip most row groups.

Inspect what the reader has to work with:

print(con.execute("""
    select row_group_id, path_in_schema, stats_min, stats_max, total_compressed_size
    from parquet_metadata('orders_sorted.parquet')
    where path_in_schema = 'ordered_at' limit 4
""").df().to_string(index=False))
 row_group_id path_in_schema  stats_min  stats_max  total_compressed_size
            0     ordered_at 2026-01-01 2026-01-02                    412
            1     ordered_at 2026-01-02 2026-01-03                    408
            2     ordered_at 2026-01-03 2026-01-05                    411
            3     ordered_at 2026-01-05 2026-01-06                    409

Narrow, non-overlapping ranges — a query for 3 January reads two row groups out of hundreds. On an unsorted file every row group spans the whole date range and nothing can be skipped.

Types survive the round trip

print(con.execute("describe select * from 'orders_snappy.parquet'").df()[['column_name','column_type']].to_string(index=False))
print()
print(con.execute("describe select * from 'orders.csv'").df()[['column_name','column_type']].to_string(index=False))
column_name   column_type
   order_id        BIGINT
customer_id        BIGINT
 ordered_at          DATE
     status       VARCHAR
     amount DECIMAL(10,2)
country_code       VARCHAR
       note       VARCHAR

column_name   column_type
   order_id        BIGINT
customer_id        BIGINT
 ordered_at          DATE
     status       VARCHAR
     amount        DOUBLE
country_code       VARCHAR
       note       VARCHAR

DECIMAL(10,2) became DOUBLE. CSV carries no types, so every reader guesses — and this particular guess turns exact money into binary floating point. Two teams reading the same CSV can infer different types from the same file, which is how two dashboards end up disagreeing by a penny.

Compression

CodecSizeWriteReadUse when
uncompressed241Mfastestfastestalmost never
snappy118Mfastfastthe default
zstd74Mmoderatefaststorage matters, or cold data
gzip71Mslowslowinterchange with tools that need it
parquet snappy   0.142s   118M
parquet zstd     0.168s    74M

zstd is 37% smaller for 18% more read time — usually the right trade for data read occasionally, while snappy stays the default for hot tables.

The small file problem

con.execute("copy orders to 'many/' (format parquet, partition_by (ordered_at, country_code))")
find many -name '*.parquet' | wc -l && du -sh many
360
131M
bench("360 small files", "select country_code, sum(amount) from 'many/**/*.parquet' group by 1 limit 1")
bench("1 large file",    "select country_code, sum(amount) from 'orders_snappy.parquet' group by 1 limit 1")
360 small files                     0.688s   ('DE', 124993750.0)
1 large file                        0.142s   ('DE', 124993750.0)

360 files is 5× slower than one, on identical data. Scale that to a streaming job writing a file a minute — 43,200 files a month — and the per-file overhead dominates entirely. Compact on a schedule; aim for 128 MB to 1 GB per file.

Choosing

FormatShapeGood atUse for
CSVrow, textuniversal, human-readableboundaries, small reference data
JSONrow, textnested structuresAPI payloads, event envelopes
Avrorow, binarywrites, schema evolutionstreaming, message queues
Parquetcolumnar, binaryanalytical readsanything queried repeatedly
Delta / IcebergParquet + a logtransactions, time traveltables, not just files

The default that serves most pipelines: accept whatever arrives, land it unchanged, and convert to Parquet as the first transformation step. Everything downstream then reads a typed, compressed, column-prunable file.

Practice

1. Write the same data as CSV and Parquet and compare sizes.
-rw-r--r-- 1 you you 412M orders.csv
-rw-r--r-- 1 you you  74M orders_zstd.parquet

5.6× smaller. On object storage that is the difference between paying for 4 TB and 700 GB — before counting the query-time saving.

2. Query two columns versus all columns from Parquet.
parquet: 2 of 7 columns   0.142s
parquet: select *         1.884s

13× from naming the columns you need. select * in a pipeline is a habit worth breaking on a columnar format.

3. Sort a file by the column you filter on and re-measure.
filter, unsorted file   0.104s
filter, sorted file     0.011s

Same rows, same format, one physical difference. This is the file-level version of the clustering decision that Snowflake and Databricks make you configure.

4. Compare 360 small files with one large one.
360 small files   0.688s
1 large file      0.142s

Per-file overhead, not data volume. Partition only when partitions will be large — the next lesson is about picking that boundary.

Next: idempotency — making a pipeline safe to run twice.

Frequently Asked Questions

Why is Parquet faster than CSV for analytics?
It stores data column by column, so a query reading three of forty columns reads only those three. It also keeps min/max statistics per row group, letting a reader skip whole blocks, and compresses far better because values in one column are similar.
When is CSV still the right choice?
As an interchange format at a boundary you do not control, for small reference data a human edits, and for debugging. Its cost is that it carries no types and no statistics, so every read re-parses text and guesses.
What is the difference between Parquet and Avro?
Parquet is columnar and optimised for reading a few columns from many rows — analytics. Avro is row-oriented and optimised for writing whole records and for schema evolution — streaming and message payloads. Many pipelines use Avro on the wire and Parquet at rest.
What is the small file problem?
Thousands of tiny files cost more in per-file overhead — open, read footer, plan — than in reading data. Aim for files in the 128 MB to 1 GB range and compact small ones; it is the most common reason a query on a small dataset is slow.