CSV, JSON and Parquet
The file formats data travels in: CSV and its many dialects, JSON and JSON Lines, Parquet as a columnar format, and the classic traps with decimal commas, leading zeros, dates and encodings.
Every night at 02:30 KBS, Kestrel Energy's billing system, writes an export file. Lucía Romero opens it in Excel to build the monthly KPI pack; Joost Bakker wants to load it into an analytics store. Whether the numbers survive that trip depends less on the tools than on the file format and on how it is read. This page shows the formats with Python, pandas and DuckDB 1.5 (as of October 2026). How to read and write CSV and JSON in Python in general is on Files and Paths; JSON itself is explained on JSON and Other Data Formats.
CSV: simple until it isn't
Comma-separated values (CSV) is a table as plain text: one record per line, fields separated by a delimiter, usually a header line with column names. Every spreadsheet, database and programming language can read and write it, which is why exports are almost always CSV. Its simplicity is also the problem: a CSV file says nothing about its delimiter, its encoding or the types of its columns.
RFC 4180: the common form
RFC 4180 (October 2005) describes
the CSV format and registers the media type text/csv.
It is an Informational document, not a standard, and says so itself:
"there is no formal specification in existence". It describes what
most programs already did:
- each record on its own line, ending in CRLF (carriage return + line feed);
- an optional header line with the same number of fields;
- fields separated by commas;
- fields that contain a comma, a double quote or a line break are enclosed in double quotes;
-
a double quote inside such a field is written twice:
"".
It says nothing about other delimiters, decimal separators, dates or
data types, and for the character set it only offers an optional
charset parameter on the media type. A revision that
would have made UTF-8 the default (draft "rfc4180bis", last updated
in August 2024) expired without becoming an RFC, so as of October
2026 RFC 4180 is still the only reference. Python's
csv module follows these rules by default:
import csv
import io
rows = [
["customer_id", "name", "note"],
["C-10005", "Bakkerij Korenschoof B.V.", "Ovens 04:00-07:00, closed Sunday"],
["C-10007", "Fietsenmakerij De Spaak", 'Wants "paper" invoices'],
["C-10010", "Drukkerij Inktvis B.V.", "Two meters:\nhall and office"],
]
buffer = io.StringIO()
csv.writer(buffer).writerows(rows)
text = buffer.getvalue()
print(text.replace("\r\n", "\\r\\n\n"), end="")
print()
for row in csv.reader(io.StringIO(text)):
print(len(row), row)
customer_id,name,note\r\n C-10005,Bakkerij Korenschoof B.V.,"Ovens 04:00-07:00, closed Sunday"\r\n C-10007,Fietsenmakerij De Spaak,"Wants ""paper"" invoices"\r\n C-10010,Drukkerij Inktvis B.V.,"Two meters: hall and office"\r\n 3 ['customer_id', 'name', 'note'] 3 ['C-10005', 'Bakkerij Korenschoof B.V.', 'Ovens 04:00-07:00, closed Sunday'] 3 ['C-10007', 'Fietsenmakerij De Spaak', 'Wants "paper" invoices'] 3 ['C-10010', 'Drukkerij Inktvis B.V.', 'Two meters:\nhall and office']
The writer added quotes only where they were needed and doubled the
quotes around "paper". The last record spans two lines in the file,
yet the reader returns it as one row with three fields. A script
that splits lines on \n and fields on
, gets both wrong, which is why you always read CSV
with a real CSV parser.
Dialects, delimiters and decimal commas
A CSV dialect is the set of formatting choices a particular CSV file uses: delimiter, quote character, how quotes are escaped, line ending, and whether there is a header. Two files can both be "CSV" and still need different settings to be read correctly.
The most visible choice is the
field delimiter, the
character that separates one field from the next: a comma, a
semicolon, a tab (often saved as .tsv) or a pipe
|. It is tied to the
decimal separator, the
character between the whole and the fractional part of a number: a
point in English (86.35) and a comma in Dutch, German
and many other locales (86,35). In those locales the
thousands separator is often a point: 1.204,75.
Excel takes the list separator it uses for CSV from the Windows region settings. Microsoft's own documentation notes that switching the decimal separator to a comma forces Excel to use a semicolon as the list separator. A Dutch Windows computer therefore writes semicolon files with decimal commas, and a CSV made on a laptop in Amsterdam can open as one long column on a laptop in Dublin. The KBS export follows the Dutch convention: semicolons, decimal commas and dates written as day-month-year.
import csv
from datetime import datetime
from decimal import Decimal
with open("kbs-export.csv", "w", encoding="utf-8", newline="") as f:
f.write(
"invoice_id;ean;period;amount_eur;status\r\n"
"INV-40026;871687400000000017;01-09-2026;86,35;open\r\n"
"INV-40030;871687400000000024;01-09-2026;640,10;paid\r\n"
"INV-40035;871687400000000031;01-09-2026;452,90;open\r\n"
)
with open("kbs-export.csv", encoding="utf-8", newline="") as f:
print(next(csv.reader(f))) # the default dialect expects commas
with open("kbs-export.csv", encoding="utf-8", newline="") as f:
dialect = csv.Sniffer().sniff(f.read(1024), delimiters=";,")
print("delimiter:", repr(dialect.delimiter))
total = Decimal("0")
with open("kbs-export.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f, delimiter=";"):
amount = Decimal(row["amount_eur"].replace(".", "").replace(",", "."))
period = datetime.strptime(row["period"], "%d-%m-%Y").date()
total += amount
print(row["invoice_id"], row["ean"], period.isoformat(), amount)
print("total", total)
['invoice_id;ean;period;amount_eur;status'] delimiter: ';' INV-40026 871687400000000017 2026-09-01 86.35 INV-40030 871687400000000024 2026-09-01 640.10 INV-40035 871687400000000031 2026-09-01 452.90 total 1179.35
With the default comma dialect, the whole header comes back as a
single field. csv.Sniffer can guess the delimiter from
a sample, but in a script that runs every night it is safer to state
it. The amount is converted by removing thousands points and
replacing the decimal comma, and stored as a Decimal,
because money should not become a floating-point number (see
rounding errors). The EAN codes (the 18-digit codes that identify a connection to
the grid) stay text.
Schema inference: tools guess the types
Because CSV carries no types, every tool that reads it into a table has to decide what each column is. Schema inference is that automatic guess: the reader samples the file, tries to parse each column as a number, a date or text, and picks a delimiter and header setting. It saves a lot of typing and is usually right, which is exactly why its mistakes go unnoticed. DuckDB calls its version the CSV sniffer:
cat > kbs-export.csv <<'CSV'
invoice_id;ean;period;amount_eur;status;due_date
INV-40026;871687400000000017;01-09-2026;86,35;open;15-10-2026
INV-40030;871687400000000024;01-09-2026;640,10;paid;15-10-2026
INV-40035;871687400000000031;01-09-2026;452,90;open;15-10-2026
CSV
duckdb -c "SELECT * FROM 'kbs-export.csv';"
duckdb -c "SELECT * FROM read_csv('kbs-export.csv', decimal_separator=',',
types={'ean': 'VARCHAR', 'amount_eur': 'DECIMAL(10,2)'});"
┌────────────┬────────────────────┬────────────┬────────────┬─────────┬────────────┐ │ invoice_id │ ean │ period │ amount_eur │ status │ due_date │ │ varchar │ int64 │ date │ varchar │ varchar │ date │ ├────────────┼────────────────────┼────────────┼────────────┼─────────┼────────────┤ │ INV-40026 │ 871687400000000017 │ 2026-09-01 │ 86,35 │ open │ 2026-10-15 │ │ INV-40030 │ 871687400000000024 │ 2026-09-01 │ 640,10 │ paid │ 2026-10-15 │ │ INV-40035 │ 871687400000000031 │ 2026-09-01 │ 452,90 │ open │ 2026-10-15 │ └────────────┴────────────────────┴────────────┴────────────┴─────────┴────────────┘ ┌────────────┬────────────────────┬────────────┬───────────────┬─────────┬────────────┐ │ invoice_id │ ean │ period │ amount_eur │ status │ due_date │ │ varchar │ varchar │ date │ decimal(10,2) │ varchar │ date │ ├────────────┼────────────────────┼────────────┼───────────────┼─────────┼────────────┤ │ INV-40026 │ 871687400000000017 │ 2026-09-01 │ 86.35 │ open │ 2026-10-15 │ │ INV-40030 │ 871687400000000024 │ 2026-09-01 │ 640.10 │ paid │ 2026-10-15 │ │ INV-40035 │ 871687400000000031 │ 2026-09-01 │ 452.90 │ open │ 2026-10-15 │ └────────────┴────────────────────┴────────────┴───────────────┴─────────┴────────────┘
The sniffer found the semicolon, the header and even the
day-month-year dates. It did not recognize the decimal
comma: amount_eur came back as text, so a
sum() on it would fail. And it made the EAN code a
64-bit integer, which happens to work for these values but would
silently drop a leading zero in any code that had one. The second
command states what the file is: decimal comma, EAN as text, amounts
as exact decimals.
pandas behaves the same way: read_csv infers types
unless you tell it otherwise. pandas 3.0 shows text columns with the
new str data type (see
Data Analysis with Python):
import pandas as pd
with open("kbs-export.csv", "w", encoding="utf-8") as f:
f.write(
"invoice_id;ean;period;amount_eur;status\n"
"INV-40026;871687400000000017;01-09-2026;86,35;open\n"
"INV-40030;871687400000000024;01-09-2026;640,10;paid\n"
"INV-40035;871687400000000031;01-09-2026;452,90;open\n"
)
naive = pd.read_csv("kbs-export.csv")
print(naive.shape)
df = pd.read_csv(
"kbs-export.csv",
sep=";",
decimal=",",
dtype={"ean": "str"},
)
df["period"] = pd.to_datetime(df["period"], format="%d-%m-%Y")
print(df)
print(df.dtypes)
df.to_parquet("invoices.parquet", index=False)
print(pd.read_parquet("invoices.parquet").dtypes)
(3, 1) invoice_id ean period amount_eur status 0 INV-40026 871687400000000017 2026-09-01 86.35 open 1 INV-40030 871687400000000024 2026-09-01 640.10 paid 2 INV-40035 871687400000000031 2026-09-01 452.90 open invoice_id str ean str period datetime64[us] amount_eur float64 status str dtype: object invoice_id str ean str period datetime64[us] amount_eur float64 status str dtype: object
The default read gives three rows and one column: the whole line.
With sep, decimal and a text type for the
EAN the table is right, and the explicit date format avoids any
guessing between day-month and month-day. Once written to Parquet,
the types travel with the file: reading it back needs no options at
all. pandas keeps amounts as float64, which is fine for
analysis; billing calculations need exact decimals.
The classic traps
Long codes and leading zeros
Codes made of digits are not numbers (see levels of measurement), but every tool that infers types will try to make them numbers. Two things go wrong:
-
Leading zeros disappear. A customer number
004417read as a number becomes4417, and no longer matches anything. -
Long codes lose digits. Excel stores numbers with
15 significant digits. An 18-digit EAN such as
871687400000000017is displayed as8.71687E+17and stored as871687400000000000: the last three digits are gone, and saving the file writes the damaged value back. A double-precision floating-point number, the type most tools use for decimals, holds about 15 to 17 significant digits and fails the same way.
ean = "871687400000000017"
as_float = float(ean)
print(as_float)
print(int(as_float))
print(int(as_float) == int(ean))
8.716874e+17 871687400000000000 False
The mechanism differs from Excel's (binary rounding instead of cutting at 15 digits), but for this code the damage is identical. Two meters whose codes differ only in the last digits now have the same code, and a lookup silently matches the wrong one. Current Excel for Microsoft 365 and Excel 2024 have "Automatic Data Conversion" options under File > Options > Data that can turn off removing leading zeros, truncating long numbers to 15 digits, converting digits around the letter "E" to scientific notation, and converting a continuous string of letters and numbers (such as MARCH1) to a date. Ordinary dates such as 01-10-2026 are still converted. The options help, but they are an Excel setting, not part of the file: the next person to open the CSV may not have switched them off. The full story of what goes wrong in spreadsheets is on When a Spreadsheet Is Not Enough.
Dates
01-10-2026 is 1 October in the Netherlands and
10/01/2026 is 1 October in the United States; each one
read with the other country's rule is a valid date in January. The
UK, like the Netherlands, puts the day first. A reader that infers
date formats can only be sure when a day above 12 appears; for a
file that only has the first of each month, it guesses. Use ISO 8601
(2026-10-01) in every file you produce (see
dates and times), and state the format when you read anyone else's.
Encodings and the byte order mark
A CSV file is bytes, and the reader has to know which character encoding turns them into text. Older Windows tools often write Windows-1252 (also called ANSI); almost everything else uses UTF-8. Reading one as the other produces mojibake or an error. Excel adds a twist: according to Microsoft, it opens a UTF-8 CSV correctly by double-clicking only if the file starts with a byte order mark (BOM); otherwise you import it through Data > Get Data > From Text/CSV. Many programs, in turn, do not expect that BOM:
import csv
note = "Correctie één dag"
print(note.encode("utf-8").decode("cp1252"))
try:
note.encode("cp1252").decode("utf-8")
except UnicodeDecodeError as error:
print("UnicodeDecodeError:", error.reason)
with open("bom.csv", "w", encoding="utf-8-sig", newline="") as f:
f.write("invoice_id;amount_eur\r\nINV-40026;86,35\r\n")
with open("bom.csv", "rb") as f:
print(f.read(3))
with open("bom.csv", encoding="utf-8", newline="") as f:
print(list(next(csv.DictReader(f, delimiter=";"))))
with open("bom.csv", encoding="utf-8-sig", newline="") as f:
print(list(next(csv.DictReader(f, delimiter=";"))))
Correctie één dag UnicodeDecodeError: invalid continuation byte b'\xef\xbb\xbf' ['\ufeffinvoice_id', 'amount_eur'] ['invoice_id', 'amount_eur']
UTF-8 bytes read as Windows-1252 give "één" instead of "één"; the
other way round, Python refuses. The BOM is three bytes at the start
of the file. Read with plain utf-8, it sticks to the
first column name, so row["invoice_id"] raises a
KeyError even though the header looks right on screen.
Python's utf-8-sig encoding removes it when present and
is harmless when it is not.
Other traps
-
Thousands separators:
1.204,75is one thousand two hundred and four euros in Dutch notation, and 1.20475 to a reader that expects a decimal point. -
Line endings: RFC 4180 uses CRLF, Linux tools LF.
Most readers accept both; a stray CR at the end of the last field
can still end up inside a value, such as
"open\r". -
Missing values: an empty field,
NULL,-andn/aall mean "nothing" to a person and something different to each tool (see missing values). - Round trips through a spreadsheet: opening a CSV in Excel and saving it again can rewrite dates, codes, decimals and the encoding in one go. Keep the original file and never edit it.
JSON and JSON Lines
JSON (JavaScript Object Notation) is the format of web APIs and application logs, and it is semi-structured: nested objects, lists and optional fields. Its current standard, RFC 8259 (December 2017, Internet Standard STD 90), settles several things CSV leaves open. JSON exchanged between systems that are not part of a closed ecosystem "MUST be encoded using UTF-8", and implementations "MUST NOT add a byte order mark" to it. Names within an object "SHOULD be unique", which leaves duplicates to each parser. The syntax and the common pitfalls are on JSON and Other Data Formats.
For analytics the important limit is numbers. RFC 8259 notes that
integers are only exactly interoperable between −(253) +
1 and 253 − 1, because many parsers, including
JavaScript's built-in JSON.parse, store every number as
a double-precision float. Python's parser keeps large integers
exact; a parser that uses floats does what the second line below
simulates:
import json
message = '{"ean": 871687400000000017, "kwh": 9.13}'
print(json.loads(message)["ean"])
print(json.loads(message, parse_int=float)["ean"])
print("largest safe integer:", 2**53 - 1)
871687400000000017 8.716874e+17 largest safe integer: 9007199254740991
An 18-digit EAN is about a hundred times larger than the safe limit.
Send identifiers as strings ("ean": "871687400000000017"), as meter-api does, and money as a string or in whole cents.
JSON Lines for data files
A single JSON document holding a million readings has to be parsed
as a whole, and one broken byte makes the whole file unreadable.
JSON Lines
solves this for data files: each line is one complete JSON value,
usually an object, so files can be appended to, split, streamed and
processed line by line. The conventions at jsonlines.org are short:
UTF-8, one valid JSON value per line, lines ending in
\n, no BOM, and the extension .jsonl. A
nearly identical convention is known as NDJSON (newline-delimited
JSON). DuckDB reads it directly:
cat > readings.jsonl <<'JSONL'
{"meter_id": "M-30002", "reading_date": "2026-09-01", "kwh_import": 8.76, "kwh_export": 3.207}
{"meter_id": "M-30005", "reading_date": "2026-09-01", "kwh_import": 5.612, "kwh_export": null}
{"meter_id": "M-30009", "reading_date": "2026-09-01", "kwh_import": 5.33}
JSONL
wc -l readings.jsonl
duckdb -c "SELECT * FROM read_json('readings.jsonl');"
3 readings.jsonl ┌──────────┬──────────────┬────────────┬────────────┐ │ meter_id │ reading_date │ kwh_import │ kwh_export │ │ varchar │ date │ double │ double │ ├──────────┼──────────────┼────────────┼────────────┤ │ M-30002 │ 2026-09-01 │ 8.76 │ 3.207 │ │ M-30005 │ 2026-09-01 │ 5.612 │ NULL │ │ M-30009 │ 2026-09-01 │ 5.33 │ NULL │ └──────────┴──────────────┴────────────┴────────────┘
DuckDB inferred a table from the objects: the ISO date string became
a date, and the third record, which has no
kwh_export at all, got NULL just like the second one,
which says null explicitly. Whether "absent" and "null"
mean the same thing is a question for the producer of the file (see
null versus missing).
Parquet: a columnar file format
Apache Parquet is an open, binary file format for analytical data that stores a table column by column, with the schema, the data types and statistics inside the file. It is the standard storage format of data lakes and lakehouses, and DuckDB, pandas, Spark, the cloud warehouses and most BI tools read it directly. The idea of storing by column, and why that suits analytics, is explained on columnar storage. The current format specification is parquet-format 2.14.0 (11 September 2026).
Inside a Parquet file
A Parquet file starts and ends with the four bytes
PAR1. In between, the rows are cut into horizontal
slices. A row group is one such
slice: a block of rows whose values are stored column by column, one
column chunk per column, each chunk split into pages. The schema,
the location of every column chunk and statistics such as the
minimum and maximum of each chunk are in the footer, which is
written after the data so that a writer can produce the file in a
single pass.
Each column is stored with a physical type (32- or 64-bit integer, floating point, byte array) and an annotation that says how to interpret it: a string is a byte array annotated as UTF-8 text, a date is a 32-bit integer annotated as a date. Writing Kestrel's invoices to both formats and reading them back shows the difference:
COPY invoices TO 'invoices.csv';
COPY invoices TO 'invoices.parquet' (FORMAT parquet);
-- What each file gives back when it is read again
SELECT column_name, c.column_type AS from_csv, p.column_type AS from_parquet
FROM (DESCRIBE SELECT * FROM 'invoices.csv') c
JOIN (DESCRIBE SELECT * FROM 'invoices.parquet') p USING (column_name)
ORDER BY column_name;
-- How Parquet stores them: a physical type plus an annotation
SELECT name, type AS physical_type, converted_type
FROM parquet_schema('invoices.parquet')
WHERE type IS NOT NULL;
┌─────────────┬──────────┬───────────────┐ │ column_name │ from_csv │ from_parquet │ │ varchar │ varchar │ varchar │ ├─────────────┼──────────┼───────────────┤ │ amount_eur │ DOUBLE │ DECIMAL(10,2) │ │ contract_id │ VARCHAR │ VARCHAR │ │ due_date │ DATE │ DATE │ │ invoice_id │ VARCHAR │ VARCHAR │ │ period │ DATE │ DATE │ │ status │ VARCHAR │ VARCHAR │ └─────────────┴──────────┴───────────────┘ ┌─────────────┬───────────────┬────────────────┐ │ name │ physical_type │ converted_type │ │ varchar │ varchar │ varchar │ ├─────────────┼───────────────┼────────────────┤ │ invoice_id │ BYTE_ARRAY │ UTF8 │ │ contract_id │ BYTE_ARRAY │ UTF8 │ │ period │ INT32 │ DATE │ │ amount_eur │ INT64 │ DECIMAL │ │ status │ BYTE_ARRAY │ UTF8 │ │ due_date │ INT32 │ DATE │ └─────────────┴───────────────┴────────────────┘
Through CSV, the exact DECIMAL(10,2) amount came back
as an approximate DOUBLE, because the sniffer only saw
text like 92.40. The Parquet file returned exactly the
types that went in: the amount is stored as a 64-bit integer number
of cents with a decimal annotation (precision 10, scale 2). The
DESCRIBE results were joined on the column name and
sorted alphabetically.
Why Parquet files are small and fast
Values in one column resemble each other: the same ten meter IDs, timestamps 15 minutes apart, readings with three decimals. Parquet exploits that with encodings such as dictionary encoding (store each distinct value once, then small numbers that refer to it), run-length encoding and delta encoding, and then compresses each page with a codec such as Snappy, gzip or Zstandard. DuckDB uses Snappy by default. A year of 15-minute readings for ten meters shows the effect:
-- A year of 15-minute readings for 10 meters: 350,400 rows
CREATE TABLE readings AS
SELECT 'M-' || (30001 + m) AS meter_id,
TIMESTAMP '2026-01-01' + INTERVAL 15 MINUTE * CAST(q AS INTEGER) AS interval_start,
CAST((q * 7919 + m * 104729) % 997 / 1000 AS DECIMAL(6,3)) AS kwh
FROM range(10) AS t(m), range(35040) AS s(q)
ORDER BY interval_start, meter_id;
COPY readings TO 'readings.csv';
COPY readings TO 'readings.parquet' (FORMAT parquet);
SELECT filename, round(size / 1e6, 2) AS megabytes
FROM read_blob('readings.*')
ORDER BY filename;
-- The footer knows the range of every column in every row group
SELECT row_group_id, row_group_num_rows AS row_count,
stats_min AS first_interval, stats_max AS last_interval
FROM parquet_metadata('readings.parquet')
WHERE path_in_schema = 'interval_start'
ORDER BY row_group_id;
┌────────────────────┬───────────┐ │ filename │ megabytes │ │ varchar │ double │ ├────────────────────┼───────────┤ │ ./readings.csv │ 11.91 │ │ ./readings.parquet │ 0.54 │ └────────────────────┴───────────┘ ┌──────────────┬───────────┬─────────────────────┬─────────────────────┐ │ row_group_id │ row_count │ first_interval │ last_interval │ │ int64 │ int64 │ varchar │ varchar │ ├──────────────┼───────────┼─────────────────────┼─────────────────────┤ │ 0 │ 122880 │ 2026-01-01 00:00:00 │ 2026-05-08 23:45:00 │ │ 1 │ 122880 │ 2026-05-09 00:00:00 │ 2026-09-13 23:45:00 │ │ 2 │ 104640 │ 2026-09-14 00:00:00 │ 2026-12-31 23:45:00 │ └──────────────┴───────────┴─────────────────────┴─────────────────────┘
The readings are generated with simple arithmetic so that the example gives the same result every time. The Parquet file is about 22 times smaller than the CSV file. That ratio is high because these columns are very regular; on messier real data a factor of several is more typical, but Parquet is practically always much smaller.
The second result shows the row groups (122,880 rows each is DuckDB's default) and the time range the footer records for each. A query for September only needs row groups 1 and 2; the reader can skip row group 0 without reading it. Combined with reading only the columns a query mentions, that is why querying a Parquet file is often faster than querying the same data in CSV, even though the data has to be decompressed.
Choosing a format
| CSV | JSON Lines | Parquet | |
|---|---|---|---|
| Readable by people | Yes, in any editor or spreadsheet | Yes, in any editor | No, binary; needs a tool |
| Types | None: everything is text, types are guessed | Strings, numbers, booleans, null; no date or exact decimal type | Full: integers, decimals, dates, timestamps, nested |
| Nesting | No | Yes | Yes |
| Size | Large | Largest: names repeated on every line | Small: encoded and compressed per column |
| Append a record | Yes | Yes | No: write a new file |
| Typical use | Exports for people and spreadsheets | Logs, events, API dumps | Analytical storage, data lakes |
A common pattern is to accept whatever the source produces, convert it to Parquet once with every reading option stated, and keep the original file next to it. All analysis then starts from the Parquet copy, whose types no tool has to guess again. For Kestrel that means the 02:30 KBS export lands as CSV and leaves the first pipeline step as Parquet; see Data Pipelines.