Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Analysis with Python (pandas, polars, DuckDB)  Module 3 – Loading data: CSV, Excel, JSON, Parquet and databases

Parquet, Arrow and choosing a file format

Why Parquet beats CSV for analysis, measured on 300,000 rows: write and read it in pandas, polars and DuckDB, use Arrow, and pick a format.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, pandas 3.0.6, numpy 2.5.3, pyarrow 25.0.1, polars 1.44.2 and duckdb 1.5.6
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Explain row-oriented versus columnar storage and why Parquet is smaller and faster to scan
  • Read and write Parquet with pandas, polars and DuckDB and pick a compression codec
  • Use Apache Arrow to move tables between pandas, polars and DuckDB
  • Choose CSV, Excel, JSON or Parquet for a given hand-off

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain row-oriented versus columnar storage and why Parquet is smaller and faster to scan
  • Read and write Parquet with pandas, polars and DuckDB and pick a compression codec
  • Use Apache Arrow to move tables between pandas, polars and DuckDB
  • Choose CSV, Excel, JSON or Parquet for a given hand-off

Before you start

On this page

Parquet is a file format that stores a table column by column instead of row by row. That one decision explains why it is the usual choice for analysis: each column is compressed on its own and compresses well, a reader can load only the columns a question needs, the file records every column’s type so dates stay dates, and statistics in the file let readers skip whole blocks of rows. CSV is still the right file for people and spreadsheets, and Excel for people who will edit the numbers.

Apache Arrow is the matching format in memory: a standard columnar layout that pandas, polars and DuckDB all understand, so a table can move between them without being rewritten as text. This lesson measures what the choice means on one table of 300,000 orders, written to every format and read back by three libraries.

Rows or columns

A CSV file stores whole rows, one after another. To add up the prices, a reader still has to read every byte of every line, split it into fields and convert the text to numbers. A Parquet file cuts the rows into row groups and stores each column of a row group together, as a compressed column chunk. Its footer holds the schema (the names and types of the columns) and the minimum and maximum value of every chunk.

A CSV stores whole rows one after another; Parquet stores each column of a row group together and ends with a footer.orders.csv: whole rows, one after anotherorders.parquet: columns stored together1,Riga,web,2026-01-04 10:12,3,412.52,Pune,app,2026-02-11 18:40,1,98.03,Lima,shop,2026-03-02 09:05,2,250.75Row group 0 (rows 1 to 50,000)Row group 1, row group 2 …Footer: schema (names and types)and min/max statistics per chunkorder_id chunk: 1, 2, 3 … (compressed)city chunk: Riga, Pune, Lima … (compressed)placed_at chunk, with min and maxsame data

Row-oriented CSV and columnar Parquet

Text description of the diagram

The upper box is a CSV file: each line holds one whole order, with its id, city, channel, time, quantity and price, and the lines follow one another. To get only the prices, a reader still has to read and split every line.

The lower box is a Parquet file holding the same data. The rows are cut into row groups (rows 1 to 50,000 in row group 0, and so on). Inside a row group, each column is stored together as a column chunk: all the order ids, then all the cities, then all the times, each chunk compressed on its own. The file ends with a footer that records the schema (column names and types) and the minimum and maximum of each column chunk.

Because of that layout, a reader can load only the columns it needs, skip a row group whose minimum and maximum show it cannot match a filter, and restore every column's type from the footer.

One table, seven files

Every example on this page uses the same seeded table of 300,000 orders, with eight columns: integers, a category, text, timestamps, decimal prices, nullable integers and booleans. The helper that makes it:

make_orders.py, imported by the examples Python · make_orders.py
"""A seeded table of 300,000 orders with eight columns of different types, imported by the examples."""
import numpy as np
import pandas as pd

CITIES = np.array(["Lagos", "Leeds", "Lima", "Osaka", "Perth", "Pune", "Quito", "Riga"])


def make_orders(n=300_000, seed=11):
    rng = np.random.default_rng(seed)
    return pd.DataFrame({
        "order_id": np.arange(1, n + 1),
        "city": pd.Categorical(CITIES[rng.integers(0, len(CITIES), n)]),
        "channel": np.array(["app", "web", "shop"])[rng.integers(0, 3, n)],
        "placed_at": pd.Timestamp("2026-01-01") + pd.to_timedelta(rng.integers(0, 86_400 * 181, n), unit="s"),
        "qty": rng.integers(1, 6, n),
        "price": rng.integers(500, 50_000, n) / 100,
        "coupon": pd.array(np.where(rng.random(n) < 0.8, pd.NA, rng.integers(1, 99, n)), dtype="Int64"),
        "is_gift": rng.random(n) < 0.1,
    })

Written once as CSV, as Parquet with four compression settings, as Feather and as JSON Lines:

Sizes of the same 300,000 rows Python · file_sizes.py
import os

from make_orders import make_orders

orders = make_orders()
orders.to_csv("orders.csv", index=False)
for codec in ["snappy", "zstd", "gzip", None]:
    orders.to_parquet(f"orders_{codec or 'none'}.parquet", compression=codec)
orders.to_feather("orders.feather")
orders.to_json("orders.jsonl", orient="records", lines=True, date_format="iso")

csv_mb = os.path.getsize("orders.csv") / 1e6
print(f"{'file':<22}{'MB':>7}{'of CSV':>9}")
for name in ["orders.csv", "orders_none.parquet", "orders_snappy.parquet", "orders_zstd.parquet", "orders_gzip.parquet", "orders.feather", "orders.jsonl"]:
    mb = os.path.getsize(name) / 1e6
    print(f"{name:<22}{mb:>7.2f}{mb / csv_mb:>9.0%}")

Output

file                       MB   of CSV
orders.csv              15.77     100%
orders_none.parquet      6.78      43%
orders_snappy.parquet    5.23      33%
orders_zstd.parquet      3.93      25%
orders_gzip.parquet      3.88      25%
orders.feather           7.95      50%
orders.jsonl            42.23     268%

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 file_sizes.py

Even without compression, Parquet needs 43 % of the CSV’s size, because numbers are stored as binary values rather than text and repeated values such as city names are dictionary-encoded: stored once per chunk and referred to by small codes. Snappy, the default codec, brings it to a third, and zstd or gzip to a quarter. Feather, Arrow’s own file format, is fast to write and read but larger, because it stays close to the layout in memory. JSON Lines repeats every field name in every record and is the largest of all.

How much the codec costs depends on the codec. This run timed writing and reading 50,000 of the orders with each:

Write and read times of three codecs Python · codec_times.py
import time

import pandas as pd

from make_orders import make_orders

orders = make_orders(n=50_000)


def best_of_three(work):
    best = float("inf")
    for _ in range(3):
        start = time.perf_counter()
        work()
        best = min(best, time.perf_counter() - start)
    return best * 1000


print(f"{'codec':<8}{'write ms':>10}{'read ms':>10}")
for codec in ["snappy", "zstd", "gzip"]:
    path = f"orders_{codec}.parquet"
    write = best_of_three(lambda: orders.to_parquet(path, compression=codec))
    read = best_of_three(lambda: pd.read_parquet(path))
    print(f"{codec:<8}{write:>10.1f}{read:>10.1f}")

Output

codec     write ms   read ms
snappy        52.6       4.2
zstd          22.2       4.8
gzip        1119.2       8.6

This output changes from run to run: the times differ on every run and on every computer

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 codec_times.py

Snappy and zstd were both quick, while gzip took far longer to write for almost no saving over zstd. A sensible default: snappy when you are unsure, zstd for files you keep or send, gzip only for a reader that needs it.

Types survive the trip

Text has no types. When pandas reads a CSV it has to guess them again, and some guesses cannot be right:

Column types after a CSV and a Parquet round trip Python · types_survive.py
import pandas as pd

from make_orders import make_orders

orders = make_orders(n=1_000)
orders.to_csv("orders.csv", index=False)
orders.to_parquet("orders.parquet")

dtypes = pd.DataFrame({
    "written": orders.dtypes.astype(str),
    "from CSV": pd.read_csv("orders.csv").dtypes.astype(str),
    "from Parquet": pd.read_parquet("orders.parquet").dtypes.astype(str),
})
print(dtypes, end="\n\n")
print("Parquet round trip identical:", pd.read_parquet("orders.parquet").equals(orders))
print("CSV round trip identical:    ", pd.read_csv("orders.csv").equals(orders))

Output

                  written from CSV    from Parquet
order_id            int64    int64           int64
city             category      str        category
channel               str      str             str
placed_at  datetime64[us]      str  datetime64[us]
qty                 int64    int64           int64
price             float64  float64         float64
coupon              Int64  float64           Int64
is_gift              bool     bool            bool

Parquet round trip identical: True
CSV round trip identical:     False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_survive.py

The CSV round trip turned the category into plain text, the timestamps into text, and the nullable integers (most orders have no coupon) into float64. Every downstream script would need the dtype and parse_dates options of the CSV lesson to repair that. The Parquet round trip gave back a table identical to the one written, because the types are stored in the file.

Reading less: columns and row groups

Columnar storage pays off when a question needs only part of the table. pd.read_parquet(path, columns=[...]) reads only the named column chunks, while read_csv(usecols=...) must still read and split every line:

Reading all columns or two, from CSV, Parquet and Feather Python · read_times.py
import time

import pandas as pd

from make_orders import make_orders

orders = make_orders()
orders.to_csv("orders.csv", index=False)
orders.to_parquet("orders.parquet")
orders.to_feather("orders.feather")


def best_of_three(read):
    best = float("inf")
    for _ in range(3):
        start = time.perf_counter()
        read()
        best = min(best, time.perf_counter() - start)
    return best


runs = {
    "CSV, all 8 columns": lambda: pd.read_csv("orders.csv"),
    "CSV, 2 columns": lambda: pd.read_csv("orders.csv", usecols=["city", "price"]),
    "Parquet, all 8 columns": lambda: pd.read_parquet("orders.parquet"),
    "Parquet, 2 columns": lambda: pd.read_parquet("orders.parquet", columns=["city", "price"]),
    "Feather, all 8 columns": lambda: pd.read_feather("orders.feather"),
}
for label, read in runs.items():
    print(f"{label:<24}{best_of_three(read) * 1000:>8.1f} ms")

Output

CSV, all 8 columns         299.3 ms
CSV, 2 columns              93.1 ms
Parquet, all 8 columns      10.3 ms
Parquet, 2 columns           5.3 ms
Feather, all 8 columns       9.7 ms

This output changes from run to run: the times differ on every run and on every computer

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_times.py

Parquet was many times faster than CSV for the whole table, and faster still for two columns. The footer’s statistics also let a reader skip row groups. Written in time order, in row groups of 50,000 rows, each group covers about a month, so a filter for March can only match two of the six groups:

Row-group statistics and a filtered read Python · row_groups.py
import pandas as pd
import pyarrow.parquet as pq

from make_orders import make_orders

# Sorted by time and written in row groups of 50,000 rows, so each group covers a narrow span of dates.
orders = make_orders().sort_values("placed_at", ignore_index=True)
orders.to_parquet("orders.parquet", row_group_size=50_000)

meta = pq.ParquetFile("orders.parquet").metadata
column = meta.schema.names.index("placed_at")
march = (pd.Timestamp("2026-03-01"), pd.Timestamp("2026-04-01"))
print(f"{meta.num_rows:,} rows in {meta.num_row_groups} row groups")
for g in range(meta.num_row_groups):
    stats = meta.row_group(g).column(column).statistics
    low, high = pd.Timestamp(stats.min), pd.Timestamp(stats.max)
    overlaps = low < march[1] and high >= march[0]
    print(f"group {g}: {low:%Y-%m-%d} to {high:%Y-%m-%d}  {'read' if overlaps else 'skipped'} for March")

in_march = pd.read_parquet("orders.parquet", filters=[("placed_at", ">=", march[0]), ("placed_at", "<", march[1])])
print(f"\nrows placed in March: {len(in_march):,}")

Output

300,000 rows in 6 row groups
group 0: 2026-01-01 to 2026-01-31  skipped for March
group 1: 2026-01-31 to 2026-03-02  read for March
group 2: 2026-03-02 to 2026-04-01  read for March
group 3: 2026-04-01 to 2026-05-01  skipped for March
group 4: 2026-05-01 to 2026-05-31  skipped for March
group 5: 2026-05-31 to 2026-06-30  skipped for March

rows placed in March: 51,250

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 row_groups.py

filters=[("placed_at", ">=", ...), ("placed_at", "<", ...)] passes the condition to the Parquet reader, which uses the same statistics to skip the groups that cannot match, and then keeps only the matching rows of the others. The skipping works only when the data is clustered: on unsorted data, every group spans the whole half-year and every group must be read. Sorting by the column people filter on most, usually a date, costs a little once, when the file is written, and every later read benefits.

A mixed column will not write

Parquet stores one type per column. A column with Python objects of different types, such as product codes read from a spreadsheet that are partly numbers and partly text, cannot be written as it is:

Two errors from one kind of column, and the fix Python · mixed_types.py
import pandas as pd

# A product-code column read from a spreadsheet: numbers and text mixed in one object column.
for values in (["A-17", 1001, None, 2.5], [1001, "A-17", None, 2.5]):
    frame = pd.DataFrame({"code": pd.Series(values, dtype="object")})
    try:
        frame.to_parquet("codes.parquet")
    except Exception as err:  # pyarrow raises ArrowTypeError or ArrowInvalid
        print(f"{type(err).__name__}: {err.args[0]}")

# Make the column text on purpose. In pandas 3, astype("str") keeps the missing value missing.
fixed = frame.assign(code=frame["code"].astype("str"))
fixed.to_parquet("codes.parquet")
back = pd.read_parquet("codes.parquet")
print(back["code"].dtype, back["code"].tolist())

Output

ArrowTypeError: Expected bytes, got a 'int' object
ArrowInvalid: Could not convert 'A-17' with type str: tried to convert to int64
str ['1001', 'A-17', nan, '2.5']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mixed_types.py

The error depends on which type comes first: pyarrow takes the first value’s type for the column and fails at the first value that does not fit. The fix is a decision about what the column is. Product codes are text, so astype("str") converts every value to text. In pandas 3 that conversion keeps missing values missing; in pandas 2, astype(str) wrote the word nan instead, according to pandas’ migration guide.

One file, three readers

A Parquet file is not tied to pandas. polars and DuckDB read the same file, with the same types, and agree on every number:

pandas, polars and DuckDB on one Parquet file Python · three_readers.py
import duckdb
import pandas as pd
import polars as pl

from make_orders import make_orders

make_orders().to_parquet("orders.parquet")

df = pd.read_parquet("orders.parquet")
print("pandas:", len(df), round((df["qty"] * df["price"]).sum(), 2))

lf = pl.read_parquet("orders.parquet")
print("polars:", lf.height, round(lf.select((pl.col("qty") * pl.col("price")).sum()).item(), 2))

rows, revenue = duckdb.sql("SELECT count(*), round(sum(qty * price), 2) FROM 'orders.parquet'").fetchone()
print("DuckDB:", rows, revenue)
print(lf.schema)

Output

pandas: 300000 227247085.1
polars: 300000 227247085.1
DuckDB: 300000 227247085.1
Schema({'order_id': Int64, 'city': Categorical, 'channel': String, 'placed_at': Datetime(time_unit='us', time_zone=None), 'qty': Int64, 'price': Float64, 'coupon': Int64, 'is_gift': Boolean})

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_readers.py

DuckDB queries the file directly with SQL, without loading it into a DataFrame first, and reads only the two columns the query uses. polars reports the same eight columns with its own type names, the category included.

Parquet, Avro & Arrow File Viewer Open a Parquet, Arrow or Avro file in the browser to see its rows, schema, row groups and statistics.

Arrow: the shared format in memory

Parquet is for files; Arrow describes how a column sits in memory. pandas 3 stores its str columns in Arrow arrays when PyArrow is installed, polars is built on the Arrow layout, and DuckDB reads and returns Arrow tables. Because they share the layout, a table can move between them through Arrow:

pandas to Arrow to polars and DuckDB, and back Python · arrow_handoff.py
import duckdb
import pandas as pd
import polars as pl
import pyarrow as pa

from make_orders import make_orders

orders = make_orders(n=1_000)

arrow_orders = pa.Table.from_pandas(orders, preserve_index=False)  # pandas to Arrow
print(arrow_orders.schema.field("city"), "|", arrow_orders.schema.field("coupon"))

frame = pl.from_arrow(arrow_orders)  # Arrow to polars
print("polars:", frame.shape)

# DuckDB queries the Arrow table through its Python variable name.
print("DuckDB:", duckdb.sql("SELECT channel, count(*) AS n FROM arrow_orders GROUP BY channel ORDER BY channel").fetchall())

# pandas 3 builds a DataFrame from anything that offers Arrow data (the PyCapsule interface).
back = pd.DataFrame.from_arrow(frame)
print("pandas from polars:", back.shape, back["coupon"].dtype)

Output

pyarrow.Field<city: dictionary<values=large_string, indices=int8, ordered=0>> | pyarrow.Field<coupon: int64>
polars: (1000, 8)
DuckDB: [('app', 345), ('shop', 321), ('web', 334)]
pandas from polars: (1000, 8) float64

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 arrow_handoff.py

  • pa.Table.from_pandas turns the DataFrame into an Arrow table; the category becomes a dictionary-encoded column (each city stored once, with small integer codes).
  • pl.from_arrow and a DuckDB query on the Arrow table work on it directly; DuckDB finds the table by its Python variable name.
  • pd.DataFrame.from_arrow, new in pandas 3, accepts any object that offers its data through the Arrow PyCapsule interface, such as a polars DataFrame or a DuckDB result. The run shows one trap: the nullable integer column came back as float64. Check dtypes after every hand-off, as after every file read.

Choosing a format for a hand-off

Files for people
Criterion CSVExcel
Reader Anyone, any toolPeople who edit numbers
Keeps types NoPartly
Size here 15.8 MBNot measured
When to choose Sharing with anyoneReports people adjust
Files for programs
Criterion JSONParquetFeather
Reader Web servicesAnalysis toolsYour next step
Keeps types PartlyYesYes
Size here 42.2 MB3.9 to 6.8 MB8.0 MB
Nested data YesYesYes
When to choose APIsStoring, sharingShort hand-offs

“Partly” means that some types survive and others do not. An Excel cell holds a number, text, a boolean or a date, so categories come back as text and integer columns with gaps as floats; JSON has numbers, text, booleans and nesting, but no dates.

Your turn: save a table safely

Write save_parquet_safely(df, path). It must write any DataFrame to Parquet so that reading the file back gives the same table, except that columns mixing numbers and text become text, with their missing values still missing. It must not change the caller’s DataFrame.

exercise/save_safely.py (your starting point) Python · exercise/save_safely.py
import pandas as pd


def save_parquet_safely(df, path):
    """Write df to Parquet so that reading it back gives the same table, with mixed columns as text."""
    df.to_parquet(path)  # fails on a column that mixes numbers and text
exercise/check_save.py, run on the starting point Python · exercise/check_save.py
import pandas as pd

from save_safely import save_parquet_safely

stock = pd.DataFrame({
    "code": pd.Series([1001, "A-17", None, "007"], dtype="object"),
    "sold_on": pd.to_datetime(["2026-04-01", "2026-04-02", None, "2026-04-03"]),
    "qty": pd.array([5, None, 2, 1], dtype="Int64"),
    "note": pd.Series(["", None, "gift", "repeat"], dtype="object"),
    "price": [18.5, 22.0, 9.99, 120.0],
})
before = stock.copy()
expected = stock.assign(code=stock["code"].astype("str"), note=stock["note"].astype("str"))


def check(name, test):
    try:
        ok = bool(test())
    except Exception:  # the write failed, for example
        ok = False
    print("PASS" if ok else "FAIL", name)
    return ok


def saved():
    save_parquet_safely(stock, "stock.parquet")
    return pd.read_parquet("stock.parquet")


back = None
try:
    back = saved()
except Exception as err:
    print(f"save_parquet_safely raised {type(err).__name__}")

results = [
    check("the file is written and read back", lambda: back is not None),
    check("the same columns in the same order", lambda: list(back.columns) == list(stock.columns)),
    check("mixed columns become text, missing values stay missing", lambda: back["code"].dtype == "str" and back["code"].isna().sum() == 1),
    check("dates are unchanged", lambda: back["sold_on"].equals(stock["sold_on"])),
    check("nullable integers stay Int64", lambda: back["qty"].dtype == "Int64" and back["qty"].isna().sum() == 1),
    check("the whole table matches", lambda: pd.testing.assert_frame_equal(back, expected) is None),
    check("the caller's DataFrame is not changed", lambda: stock.equals(before)),
]
print(f"{sum(results)} of {len(results)} checks pass")

Output

save_parquet_safely raised ArrowInvalid
FAIL the file is written and read back
FAIL the same columns in the same order
FAIL mixed columns become text, missing values stay missing
FAIL dates are unchanged
FAIL nullable integers stay Int64
FAIL the whole table matches
PASS the caller's DataFrame is not changed
1 of 7 checks pass

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_save.py

Place both files next to each other and change nothing but save_safely.py; once the function is right, python check_save.py passes all seven of its checks. Hints: find the columns whose dtype is object, convert a copy rather than the original, and leave every other column alone, so the dates and the nullable integers keep their types.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Which are reasons a columnar file such as Parquet is faster to query than CSV?

    Choose every answer that is right.

    Show the answer to question 1

    Answer:

    • Statistics in the footer let a reader skip row groups that cannot match a filter
    • A reader can load only the columns a query needs
    • Values are stored in binary form, so they are not parsed from text on every read

    Columnar storage, binary values and per-chunk statistics are what make the difference. A Parquet file is only sorted if you sort the data before writing it, which is what makes row-group skipping effective.

  2. Question 2 of 5 A DataFrame with a nullable Int64 column (some values missing) is written to CSV and read back. What dtype does that column have?

    Choose one answer.

    Show the answer to question 2

    Answer: float64

    CSV stores text only, so read_csv guesses again. An integer column with empty cells becomes float64, because NumPy integers cannot hold a missing value. The Parquet round trip in the lesson kept it as Int64.

  3. Question 3 of 5 A Parquet file of a year of orders was written without sorting. Will a filter for one month skip row groups?

    Choose one answer.

    Show the answer to question 3

    Answer: Hardly ever, because every row group's minimum and maximum span the whole year

    A reader skips a row group only when the group's statistics show it cannot match. On unsorted data every group holds dates from the whole year, so every group must be read. Sorting by the date before writing makes each group cover a narrow span.

  4. Question 4 of 5 to_parquet fails with ArrowTypeError on a column of product codes that mixes numbers and text. What is the right fix?

    Choose one answer.

    Show the answer to question 4

    Answer: Convert the column to text with astype("str") before writing

    Parquet stores one type per column, and product codes are text. In pandas 3, astype("str") converts every value to text and keeps missing values missing. The codec has nothing to do with it, Feather has the same one-type rule, and dropping rows loses data.

  5. Question 5 of 5 A finance colleague wants this month's figures to check and adjust by hand. Which format do you send?

    Choose one answer.

    Show the answer to question 5

    Answer: An Excel workbook

    Choose the format for its reader. People who will edit numbers need a spreadsheet. Parquet and Feather suit analysis tools and pipelines, and JSON Lines suits programs.

Interview questions

Fresher · Why is Parquet usually smaller and faster to query than CSV?

CSV stores each row as text, so numbers take as many bytes as they have digits, every value must be parsed again on every read, and a reader must scan whole lines even to get one column. Parquet stores data by column: each column chunk holds values of one type in binary form and is compressed on its own, which works well because a column’s values are alike. A query that needs two columns reads only those chunks, and the minimum and maximum kept for each chunk let a reader skip row groups that cannot match a filter. The schema is stored in the file, so types survive without guessing. In this lesson’s run, 300,000 orders took 15.8 MB as CSV and 5.2 MB as snappy-compressed Parquet, which pandas read many times faster.

Key takeaways

  • Parquet stores columns together, compressed, with the schema and min/max statistics in the footer; CSV stores rows of text.
  • On 300,000 orders, Parquet took 25 to 43 % of the CSV’s size, depending on the codec; snappy is the default, zstd is smaller at similar speed, and gzip was far slower to write.
  • Types survive Parquet; a CSV round trip lost the category, the timestamps and the nullable integers.
  • columns=[...] reads only some column chunks, and filters skips row groups, if the data was sorted by the filter column before writing.
  • A column that mixes numbers and text will not write; convert it with astype("str"), which keeps missing values missing in pandas 3.
  • pandas, polars and DuckDB read the same file and agree; DuckDB queries it with SQL directly.
  • Arrow is the shared format in memory: pa.Table.from_pandas, pl.from_arrow and pd.DataFrame.from_arrow move tables between libraries, but check the dtypes after each move.
  • Hand people CSV or Excel, programs JSON, analysis Parquet, and your own next step Feather.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.