Math, Statistics, and Datapandas and Data Wrangling › Day 121

Hands-on lab — Day 121: Loading and Inspecting Data

Commands

Setup

cd labs/sections/math-statistics-and-data/day-121-loading-and-inspecting-data
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

Run

cd examples && ../.venv/bin/python3 01_the_namibia_trap.py && cd ..
cd examples && ../.venv/bin/python3 02_leading_zeros.py && cd ..
cd examples && ../.venv/bin/python3 03_precision_loss.py && cd ..
cd examples && ../.venv/bin/python3 04_dates.py && cd ..
cd examples && ../.venv/bin/python3 05_encoding.py && cd ..
cd examples && ../.venv/bin/python3 06_chunking.py && cd ..
cd examples && ../.venv/bin/python3 07_csv_vs_parquet.py && cd ..
cd examples && ../.venv/bin/python3 08_inspection_battery.py && cd ..
cd examples && ../.venv/bin/python3 09_category_memory.py && cd ..
cd examples && ../.venv/bin/python3 10_other_formats.py && cd ..
.venv/bin/python3 starter/check_progress.py

Test

bash tests/run_tests.sh

File tree

examples/01_the_namibia_trap.py
examples/02_leading_zeros.py
examples/03_precision_loss.py
examples/04_dates.py
examples/05_encoding.py
examples/06_chunking.py
examples/07_csv_vs_parquet.py
examples/08_inspection_battery.py
examples/09_category_memory.py
examples/10_other_formats.py
expected-output/01-the-namibia-trap.txt
expected-output/02-leading-zeros.txt
expected-output/03-precision-loss.txt
expected-output/04-dates.txt
expected-output/05-encoding.txt
expected-output/06-chunking.txt
expected-output/07-csv-vs-parquet.txt
expected-output/08-inspection-battery.txt
expected-output/09-category-memory.txt
expected-output/10-other-formats.txt
expected-output/FIELDS.md
expected-output/starter-progress.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/check_progress.py
starter/exercises.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 121 lab — Read It Right

Lesson

Purpose

Nine numbered exercises, each proving one specific way read_csv() — and its siblings for JSON, Parquet and SQL — silently guesses wrong, on pandas 3.0.5 specifically. read_csv() is a type-inference engine wearing the costume of a file reader: it guesses correctly most of the time, and when it guesses wrong, nothing raises. You get different data than the file contained, and every downstream computation runs on it without complaint.

Every file this lab reads is written by the lab itself, into a temporary directory it creates and deletes; nothing is downloaded, and nothing is left behind — this lab proves that with a real check, not a promise.

The throughline is the moment the file is read. Exercise 1 opens with the famous case: a country-code column contains NA for Namibia, and pandas' default na_values list includes the literal string "NA" — so Namibia silently becomes a missing value. Exercise 2 hits the second one almost everyone meets at work within a month: an identifier column of 00123 becomes the integer 123, leading zeros gone, with no warning anywhere.

Learning objectives

By the end of this lab you will be able to:

  • Explain why read_csv() reading "NA" as missing, by default, is not a bug — and use keep_default_na=False when a literal "NA" value must survive.
  • Preserve a leading-zero identifier column with dtype={"col": "str"} instead of letting it silently become a shorter integer.
  • Demonstrate that an integer above 2**53 survives read_csv()'s int64 inference exactly, but is silently corrupted by a float64 cast.
  • State what parse_dates changes about a date column's dtype, and show a concrete case where a string-sorted date column returns the wrong chronological order.
  • Diagnose an encoding mismatch (UnicodeDecodeError or mojibake) and fix it by naming the correct encoding= argument.
  • Read a file larger than memory with chunksize, and confirm a chunk-by-chunk aggregate equals the whole-file answer exactly.
  • State, and demonstrate with a real round-trip, why CSV loses dtypes and Parquet preserves them exactly — the strongest practical argument against using CSV as an interchange format between your own programs.
  • Run the eight-command inspection battery (.head(), .info(), .dtypes, .describe(), .isna().sum(), .nunique(), .value_counts(), memory_usage(deep=True)) on an unfamiliar frame and say what each command is for.
  • Convert a low-cardinality string column to category and measure the memory reduction as a ratio, not a byte count.

Prerequisites

  • Day 120 — pandas Series and DataFrames: the index, dtypes including the pandas-3.0 str default and Int64/int64 promotion, and Copy-on-Write. This lab assumes that foundation and does not re-teach it.
  • Days 92–98 — data formats and pipelines, and the habit of reading data before trusting it.
  • Week 13 (SQL) — exercise 10's read_sql() demonstration runs a real query against a real sqlite3 connection; no SQL beyond a SELECT ... WHERE is required.
  • A working python3 on your PATH to create the lab's virtual environment.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64
Linux (any current distribution) Expected identical, given the pinned versions below
Windows Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it

Hardware requirements

Anything. The largest file this lab writes is a 50,000-row, single-column CSV for the chunking exercise, a few hundred kilobytes on disk. No GPU, no meaningful disk use, and no network beyond the one-time install.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
pandas 3.0.5 exactly 3.0.5 Pinned exactly — see requirements/README.md for why
pyarrow 25.0.1 25.0.1 Backs to_parquet()/read_parquet() and pandas 3.0's str/Int64 dtypes
numpy 2.5.2 2.5.2 Seeded random columns for the chunking and category-memory exercises
bash 3.2 3.2.57 The test harness

sqlite3, csv, json and io are all standard library — nothing extra to install for exercise 10 or the inspection battery.

Check your Python in one line: python3 --version.

Free and open-source options

Everything here is free.

  • pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and PyArrow (Apache 2.0) are fully open source with no paid tier.
  • SQLite (public domain) is the database engine behind exercise 10's read_sql() demonstration — no server to install or run.
  • polars (MIT), described from its documentation in the lesson's Tools section rather than run here, offers a lazy scan_csv() that defers reading until a query actually needs the data — a different answer to the "file larger than memory" problem than pandas' chunksize.
  • openpyxl (MIT), also described from documentation only, is the library pandas.read_excel() uses under the hood for .xlsx files.

No account, no key, no paid tier, and no part of this lab is degraded without one.

Installation

cd labs/sections/math-statistics-and-data/day-121-loading-and-inspecting-data
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

If your tools live somewhere unusual, tests/run_tests.sh takes an override rather than guessing:

PYTHON=/path/to/python3 bash tests/run_tests.sh

File structure

day-121-loading-and-inspecting-data/
├── README.md                        this file
├── metadata.yml                     lab metadata and the recorded run
├── security.md                      what this lab does to your machine
├── troubleshooting.md               grouped by the message you actually see
├── requirements/
│   ├── README.md                    versions, and why they are pinned exactly
│   └── requirements.txt             pandas==3.0.5, pyarrow==25.0.1, numpy==2.5.2
├── starter/                         YOUR work happens here
│   ├── exercises.py                  nine functions, one blank each
│   └── check_progress.py             "N of 9 exercises complete."
├── examples/                        the reference. Read AFTER you have tried
│   ├── 01_the_namibia_trap.py
│   ├── 02_leading_zeros.py
│   ├── 03_precision_loss.py
│   ├── 04_dates.py
│   ├── 05_encoding.py
│   ├── 06_chunking.py
│   ├── 07_csv_vs_parquet.py
│   ├── 08_inspection_battery.py
│   ├── 09_category_memory.py
│   └── 10_other_formats.py           supplementary: JSON, sqlite3, stdlib csv
├── tests/
│   └── run_tests.sh                 42 checks of real values
└── expected-output/                  captured from a real run on 2026-08-19
    ├── FIELDS.md                     what must match and what may differ
    ├── 01-the-namibia-trap.txt ... 10-other-formats.txt
    ├── starter-progress.txt          0 of 9 before you begin
    └── test-run.txt                  the full harness run

How to run

## 1. The whole thing. Start here — it should be green before you change
##    anything, and green again when you have finished.
bash tests/run_tests.sh
echo "exit code: $?"

## 2. Find out where you stand on the exercises. It will say 0 of 9.
.venv/bin/python3 starter/check_progress.py

## 3. Open starter/exercises.py and replace each `_FILL_THIS_IN` with real
##    code, re-running step 2 as you go.

## --- everything below is the reference. Look after you have tried. ---

## 4. Run any single reference script directly.
cd examples
../.venv/bin/python3 01_the_namibia_trap.py
../.venv/bin/python3 02_leading_zeros.py
../.venv/bin/python3 03_precision_loss.py
../.venv/bin/python3 04_dates.py
../.venv/bin/python3 05_encoding.py
../.venv/bin/python3 06_chunking.py
../.venv/bin/python3 07_csv_vs_parquet.py
../.venv/bin/python3 08_inspection_battery.py
../.venv/bin/python3 09_category_memory.py
../.venv/bin/python3 10_other_formats.py
cd ..

What the commands do

bash tests/run_tests.sh confirms the installed pandas matches requirements.txt exactly, runs all ten reference scripts and checks each exits 0 with every internal assertion held, runs starter/check_progress.py on the untouched checkout and confirms it honestly reports 0 of 9, then solves every blank in a scratch copy (never touching the real starter/exercises.py) and confirms the checker reports 9 of 9 with exit 0. It then re-checks the lesson's sharpest claims independently in one Python process, deliberately breaks one assertion to prove the suite can fail, restores it, and confirms nothing was left on disk — including a dedicated check that no .csv, .parquet or .db file survives anywhere in the lab.

.venv/bin/python3 starter/check_progress.py runs your starter/exercises.py, catching the NameError an unfilled _FILL_THIS_IN raises so one incomplete exercise does not stop the others from being checked, and reports each one as complete, wrong, or not yet attempted.

Each examples/0N_*.py script is self-contained: it writes whatever small file the exercise needs into a temporary directory it created, reads it back, prints what it found, asserts the real values against independently computed expectations, deletes the temporary directory, and ends with 0N_name.py: every assertion held. on success.

Expected output

The harness ends with a real captured line:

42 checks, 0 failure(s).

and exits 0. starter/check_progress.py reports 0 of 9 exercises complete. with exit 1 on an untouched checkout.

The day's two sharpest facts, exactly as captured:

default read:  NA for Namibia -> NaN (missing)
keep_default_na=False:  NA for Namibia -> 'NA' (the literal string)
order_id (nullable Int64, one missing value):
  after CSV round-trip:      float64  -- 1001.0, 1002.0, NaN
  after Parquet round-trip:  Int64    -- 1001, 1002, <NA>  (exact)

The full capture of every script is in expected-output/, and expected-output/FIELDS.md says which values are specific to pandas 3.0.5 and would legitimately differ on 2.x, and which would not differ on any correctly-installed copy of this exact version.

Validation steps

  1. bash tests/run_tests.sh ends with 42 checks, 0 failure(s). and exits 0.
  2. The default read of a country-code CSV turns Namibia's NA into a real missing value; keep_default_na=False keeps it as the string 'NA'.
  3. The default read of id column "00123" gives the integer 123; dtype={"id": "str"} gives '00123' exactly.
  4. An integer past 2**53 survives int64 inference exactly, and loses exactly its last digit through a float64 cast.
  5. A date column left unparsed is the str dtype and sorts lexically — getting the chronological order wrong the moment one date drops a leading zero; parse_dates=[...] gives the datetime64 dtype and sorts correctly.
  6. Reading a latin-1 file with encoding="utf-8" raises UnicodeDecodeError; the correct encoding round-trips the text exactly.
  7. An aggregate computed chunk-by-chunk (chunksize=1000, and again with an odd chunksize=777) equals the whole-file aggregate exactly.
  8. A CSV round-trip changes at least one dtype (a nullable Int64 column with a missing value becomes float64); a Parquet round-trip preserves every dtype, and every value, exactly.
  9. The inspection battery reports exact, known values on a constructed frame: 1 missing value per column, north as the top value_counts() entry with count 4.
  10. Converting a low-cardinality string column to category reduces memory_usage(deep=True) by at least 5x (this run measured roughly 12.4x — a ratio, not a promise).

Tests

bash tests/run_tests.sh
echo "exit code: $?"

42 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: every reference script's internal assertions are exercised, the lesson's sharpest claims are re-checked independently in a second pass, and the starter checker is exercised both incomplete and fully solved.

The suite also proves it is not vacuous: section 5 deliberately breaks the assertion inside 03_precision_loss.py, confirms the run exits non-zero with a printed FAIL: line, restores the file, and confirms it passes again.

Override, if your tools are somewhere unusual:

PYTHON=/path/to/python3 bash tests/run_tests.sh

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache

Every exercise script writes into its own tempfile.mkdtemp() directory and removes it, in a finally: block, before exiting — including when an assertion fails. tests/run_tests.sh also clears __pycache__ and .pytest_cache both before and after it runs, and independently checks that no .csv, .parquet or .db file survives anywhere in the lab — so if you only ran the harness or the reference scripts, there is nothing left to clean up.

To remove the lab's virtual environment entirely: rm -rf .venv.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • Exercise 1's code column doesn't come back as missing for Namibia — you are running with keep_default_na=False already, or an older pandas with a different na_values default.
  • Exercise 3's precision numbers look "off by more than one" — confirm 2**53 + 1 is computed in Python, not truncated by a shell.
  • Exercise 5 doesn't raise UnicodeDecodeError — some byte sequences are valid under both encodings and mojibake silently instead; that is the other half of the danger this exercise is about.
  • A .csv/.parquet/.db file is left behind — a script was likely interrupted before its cleanup ran; re-run the harness.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its three pinned packages, and everything else runs offline, writes only into .venv/ and per-exercise temporary directories it deletes itself, needs no credential, and touches no real data — every value in every exercise is a small invented literal or a seeded random column generated purely to make the chunking and category-memory exercises meaningful at scale.

Extension exercises

  1. Reproduce exercise 5 with a byte sequence that mojibakes instead of raising. Find (or construct) a short latin-1 string whose bytes also happen to decode as valid — but different — UTF-8, and show the silent wrong-text result side by side with this lab's loud UnicodeDecodeError case. Write one sentence on which failure mode is more dangerous in a real pipeline and why.
  2. Measure the CSV-versus-Parquet gap at scale. Build a 500,000-row DataFrame with a mix of Int64, float64, bool and str columns, round-trip it through both formats, and compare not just dtypes but file size and read time. Report the file-size ratio as a ratio, not a byte count.
  3. Use chunksize to compute something .sum() cannot: a running maximum. Read a CSV in chunks and compute the true whole-file maximum without ever loading the whole file into memory at once. Confirm it equals the whole-file .max().
  4. Read the polars documentation on scan_csv() and lazy evaluation. Write down, from the documentation alone (polars is not installed here), what specifically it defers that pandas' chunksize does not, and when that difference would matter for a file that does not fit in memory even one chunk at a time.
  5. Find your own real "NA" trap. Pick any small, real dataset you have access to (or a public one you already trust) and run pd.read_csv(path, keep_default_na=False) next to the default read. Diff the two frames column by column and write down every value that changed — this is the fastest way to discover whether a dataset you already use has silently absorbed a false-missing value.
  • Previous day: Day 120 — pandas: Series and DataFrames (labs/sections/math-statistics-and-data/day-120-pandas-series-and-dataframes/).
  • Next day: Day 122 — Selecting and Filtering (labs/sections/math-statistics-and-data/day-122-selecting-and-filtering/).
  • Week 18 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-18/), "Messy Dataset Rescue" — building directly on the loading and inspection habits from this lab.

Expected output

01-the-namibia-trap.txt

default read:
  code        country
0  NaN        Namibia
1   US  United States
2   FR         France

keep_default_na=False:
  code        country
0   NA        Namibia
1   US  United States
2   FR         France
  ok: the default read turns Namibia's 'NA' into a real missing value
  ok: the default read leaves the OTHER two codes untouched
  ok: keep_default_na=False keeps 'NA' as the literal string
  ok: keep_default_na=False does not turn anything else into a string it wasn't
  ok: both reads keep all three rows

5 checks, 0 failure(s).
01_the_namibia_trap.py: every assertion held.

02-leading-zeros.txt

default read:
    id   name
0  123  Alice
1  456    Bob
2  789  Carla
id      int64
name      str
dtype: object

dtype={'id': 'str'}:
      id   name
0  00123  Alice
1  00456    Bob
2  00789  Carla
id      str
name    str
dtype: object
  ok: the default read infers id as int64
  ok: the default read silently drops the leading zeros: '00123' becomes 123
  ok: dtype={'id': 'str'} keeps id as the str dtype
  ok: dtype={'id': 'str'} preserves the leading zeros exactly
  ok: the two reads disagree on the very same cell

5 checks, 0 failure(s).
02_leading_zeros.py: every assertion held.

03-precision-loss.txt

2**53 + 1 = 9007199254740993

read_csv() infers:
order_id    int64
dtype: object
value as read: 9007199254740993
  ok: read_csv() infers the column as int64
  ok: int64 preserves the ID exactly, digit for digit

after .astype('float64'):
order_id    float64
dtype: object
value after float64 round-trip: 9007199254740992
exact:    9007199254740993
corrupted:9007199254740992
  ok: the float64 round-trip silently changes the value
  ok: the corrupted value is exactly one less than the true ID -- the last digit collapsed
  ok: both numbers have the same number of digits (16)

first differing digit is at position 15: '3' (exact) vs '2' (corrupted)
  ok: the two numbers agree on every digit except the very last one

6 checks, 0 failure(s).
03_precision_loss.py: every assertion held.

04-dates.txt

without parse_dates:
    event        date
0   first  2024-01-05
1  second  2024-01-20
2   third    2024-1-9
event    str
date     str
dtype: object

with parse_dates=['date']:
    event       date
0   first 2024-01-05
1  second 2024-01-20
2   third 2024-01-09
event               str
date     datetime64[us]
dtype: object
  ok: without parse_dates the column is the str dtype
  ok: with parse_dates the column becomes a real datetime64 dtype

string-sorted event order:   ['first', 'second', 'third']
parsed-datetime event order: ['first', 'third', 'second']
  ok: the true chronological order is first, third, second
  ok: the raw string sort gets this WRONG -- it puts 'third' last
  ok: the two sort orders genuinely disagree, proving the string sort is unsafe

5 checks, 0 failure(s).
04_dates.py: every assertion held.

05-encoding.txt

reading with encoding='utf-8' (WRONG for this file): UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: unexpected end of data
  ok: reading a latin-1 file as UTF-8 raises UnicodeDecodeError -- it does not silently succeed

reading with encoding='latin-1' (correct for this file):
   name       city
0  José  São Paulo
  ok: reading with the correct encoding recovers the accented name exactly
  ok: reading with the correct encoding recovers the accented city exactly
  ok: the undecorated default (no encoding= argument) behaves identically to encoding='utf-8'

4 checks, 0 failure(s).
05_encoding.py: every assertion held.

06-chunking.txt

wrote 50000 rows to a temporary CSV
whole-file sum: 24972031
chunked sum (50 chunks of up to 1,000 rows): 24972031
  ok: chunking visits every row exactly once
  ok: chunking produces the expected number of chunks
  ok: the chunk-by-chunk sum equals the whole-file sum exactly
  ok: row count accumulated over odd-sized chunks (777) still matches exactly

4 checks, 0 failure(s).
06_chunking.py: every assertion held.

07-csv-vs-parquet.txt

original dtypes:
order_id      Int64
price       float64
in_stock       bool
category        str
dtype: object

dtypes after a CSV round-trip:
order_id    float64
price       float64
in_stock       bool
category        str
dtype: object
   order_id  price  in_stock category
0    1001.0  19.99      True    books
1    1002.0  44.50     False    tools
2       NaN   7.25      True    books

dtypes after a Parquet round-trip:
order_id      Int64
price       float64
in_stock       bool
category        str
dtype: object
   order_id  price  in_stock category
0      1001  19.99      True    books
1      1002  44.50     False    tools
2      <NA>   7.25      True    books

columns whose dtype changed via CSV:     ['order_id']
columns whose dtype changed via Parquet: []
  ok: the CSV round-trip changes at least one column's dtype
  ok: specifically, the nullable Int64 order_id column is not what it started as after CSV
  ok: the missing order_id survives the CSV round-trip only as a float NaN, not pd.NA
  ok: the Parquet round-trip preserves every column's dtype EXACTLY
  ok: the Parquet round-trip keeps order_id as the nullable Int64 dtype, missing value and all
  ok: the Parquet round-trip's actual values equal the originals exactly, not just the dtypes

6 checks, 0 failure(s).
07_csv_vs_parquet.py: every assertion held.

08-inspection-battery.txt

the frame under inspection:
  region  amount
0  north    10.0
1  south    20.0
2  north     NaN
3   east    15.0
4  north    30.0
5  south    20.0
6    NaN    25.0
7  north    10.0

1. .head(3):
  region  amount
0  north    10.0
1  south    20.0
2  north     NaN
  ok: .head(3) returns exactly 3 rows

2. .info():
<class 'pandas.DataFrame'>
RangeIndex: 8 entries, 0 to 7
Data columns (total 2 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   region  7 non-null      str    
 1   amount  7 non-null      float64
dtypes: float64(1), str(1)
memory usage: 295.0 bytes

  ok: .info() reports the correct row count (8 entries)
  ok: .info() names both columns
3. .dtypes:
region        str
amount    float64
dtype: object
  ok: region is the pandas-3.0 str dtype
  ok: amount, with a None in it, is float64 (int64 has no missing-value slot)

4. .describe():
          amount
count   7.000000
mean   18.571429
std     7.480132
min    10.000000
25%    12.500000
50%    20.000000
75%    22.500000
max    30.000000
  ok: .describe() counts only the 7 non-missing amount values

5. .isna().sum():
region    1
amount    1
dtype: int64
  ok: region has exactly 1 missing value
  ok: amount has exactly 1 missing value

6. .nunique():
region    3
amount    5
dtype: int64
  ok: region has exactly 3 distinct non-missing values (north, south, east)
  ok: amount has exactly 5 distinct non-missing values (10, 20, 15, 30, 25)

7. region.value_counts():
region
north    4
south    2
east     1
Name: count, dtype: int64
  ok: the single most common region is 'north'
  ok: 'north' appears exactly 4 times

8. memory_usage(deep=True):
Index     132
region     99
amount     64
dtype: int64
  ok: memory_usage(deep=True) reports a positive byte count for every column

13 checks, 0 failure(s).
08_inspection_battery.py: every assertion held.

09-category-memory.txt

20000 rows, 4 distinct region values
region dtype: str

memory_usage(deep=True) as str:   250,204 bytes
memory_usage(deep=True) as category:            20,183 bytes
reduction ratio: 12.40x
  ok: the category dtype has the same 4 distinct values as the original
  ok: the category column is genuinely smaller than the string column
  ok: the reduction ratio clears at least 5x on this 4-category, 20,000-row column
  ok: converting to category does not change the actual values, only the storage

4 checks, 0 failure(s).
09_category_memory.py: every assertion held.

10-other-formats.txt

read_json() on a list of records:
   id   name  active
0   1  Alice    True
1   2    Bob   False
id        int64
name        str
active     bool
dtype: object
  ok: read_json() recovers all 2 records
  ok: read_json() infers 'active' as a real boolean column

read_sql() against a real sqlite3 connection:
   order_id  amount
0         1   19.99
1         2   44.50
  ok: read_sql()'s WHERE filter runs inside the database, not in pandas
  ok: read_sql() returns the correct rows

stdlib csv.DictReader (no type inference at all):
[{'id': '001', 'code': '00A'}, {'id': '002', 'code': '00B'}]
  ok: csv.DictReader keeps every field as a plain str, leading zeros included
  ok: read_csv(), by contrast, would infer 'id' as an integer and drop those zeros

6 checks, 0 failure(s).
10_other_formats.py: every assertion held.

FIELDS.md

# What is captured here, and what may legitimately differ

All files in this directory are captured verbatim from real runs on the
authoring machine, on the date recorded in `metadata.yml`. `<repo>` stands
in for the absolute path to this repository on that machine.

## Values that are pandas-3.0-specific — will differ on pandas < 3.0

- **`08-inspection-battery.txt`** — `.dtypes` and `.info()` print `str` for
  the `region` column. On any pandas release before 3.0, the same column
  would print `object` instead, because pandas 3.0 changed the default
  dtype for a column of Python strings.
- **`04-dates.txt`** — the unparsed `date` column's dtype is reported as
  `str`; before pandas 3.0 it would print `object`. The chronological
  comparison itself (string sort disagreeing with datetime sort) does not
  depend on this and would reproduce identically on any pandas version.
- **`10-other-formats.txt`** — `read_json()`'s inferred `name` column dtype
  is `str` for the same reason.

## Values that would legitimately differ on another machine, but not by version

- **`09-category-memory.txt`** — the exact byte counts (`250,204` and
  `20,183` in this run) depend on the specific random values
  `np.random.default_rng(42)` produced for this pandas/NumPy build and on
  the platform's exact string-object overhead. The lab's own test asserts
  the **ratio** clears 5x, not the specific byte counts, precisely because
  of this. The ratio measured on this run was **12.40x**.
- **`06-chunking.txt`** — the specific sum (`24972031` for the 06 example
  script's 50,000-row column, or whatever the harness's independent
  100-row check in section 4 produces) depends on the seeded random
  values, which are themselves stable given the same NumPy version and
  seed, but are not asserted as a fixed literal anywhere except within a
  single run's own two computations of the same file.
- **`platform`** line printed in `test-run.txt`'s section 1 — will read
  differently on Linux or Windows/WSL; only the pandas/pyarrow/NumPy
  version lines are asserted against `requirements.txt`.

## Values that are exact and version-independent

- **`01-the-namibia-trap.txt`**, **`02-leading-zeros.txt`** — the Namibia
  `NA`-as-missing-value behaviour and the leading-zeros-lost-by-default
  behaviour are both driven by `read_csv()`'s type-inference defaults,
  which have been stable across every recent pandas major release. These
  values do not depend on the 3.0 string-dtype change at all.
- **`03-precision-loss.txt`** — `2**53 + 1 = 9007199254740993` surviving
  `int64` inference exactly and losing its last digit through a `float64`
  cast is IEEE 754 floating-point behavior, inherited from the platform's
  double-precision float representation, not from pandas' version.
- **`07-csv-vs-parquet.txt`** — the CSV round-trip promoting a nullable
  `Int64` column with a missing value to `float64`, and the Parquet
  round-trip preserving `Int64` exactly, are both driven by CSV's
  plain-text-with-inference nature versus Parquet's typed columnar
  storage — not by the pandas-3.0 string-dtype change.
- **`05-encoding.txt`** — `UnicodeDecodeError` on a latin-1 file read as
  UTF-8 is Python's standard-library `codecs` behaviour, unrelated to
  pandas' version.

## Absolute paths

No absolute path from the authoring machine appears in any captured file —
every script here writes into a directory created by `tempfile.mkdtemp()`
at run time and reports only relative filenames or values, never the full
temporary path.

starter-progress.txt

  1. the Namibia trap: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  2. leading zeros: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  3. precision loss: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  4. dates: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  5. encoding: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  6. chunking: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  7. CSV vs Parquet: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  8. inspection battery: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  9. category memory: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)

0 of 9 exercises complete.

test-run.txt

Day 121 — Read It Right

1. The tools and the versions this lab was written against
  python   3.14.0
  pandas   3.0.5
  pyarrow  25.0.1
  numpy    2.5.2
  platform macOS-26.5.2-arm64-arm-64bit-Mach-O
  exe      python3
  ok: installed pandas matches requirements.txt
  ok: pandas is version 3 or later (this lab's captured output is 3.0.5-specific)

2. Every reference script runs and every assertion inside it holds
  ok: 01_the_namibia_trap.py exits 0
  ok: 01_the_namibia_trap.py reports every assertion held
  ok: 02_leading_zeros.py exits 0
  ok: 02_leading_zeros.py reports every assertion held
  ok: 03_precision_loss.py exits 0
  ok: 03_precision_loss.py reports every assertion held
  ok: 04_dates.py exits 0
  ok: 04_dates.py reports every assertion held
  ok: 05_encoding.py exits 0
  ok: 05_encoding.py reports every assertion held
  ok: 06_chunking.py exits 0
  ok: 06_chunking.py reports every assertion held
  ok: 07_csv_vs_parquet.py exits 0
  ok: 07_csv_vs_parquet.py reports every assertion held
  ok: 08_inspection_battery.py exits 0
  ok: 08_inspection_battery.py reports every assertion held
  ok: 09_category_memory.py exits 0
  ok: 09_category_memory.py reports every assertion held
  ok: 10_other_formats.py exits 0
  ok: 10_other_formats.py reports every assertion held

3. The starter checker: honest progress, both directions
    9. category memory: NOT YET COMPLETE (name '_FILL_THIS_IN' is not defined)
  
  0 of 9 exercises complete.
  ok: an untouched starter checkout reports 0 of 9 complete
  ok: the starter checker exits non-zero when incomplete
    9. category memory: correct  (got (13132, 1159))
  
  9 of 9 exercises complete.
  ok: a fully solved copy reports 9 of 9 complete
  ok: the checker exits 0 once every exercise is correct

4. The lesson's sharpest claims, checked one value at a time
  default_is_missing True
  kept_value NA
  default_id 123
  typed_id 00123
  precision_exact True
  precision_corrupted_by_one 1
  csv_dtype float64
  parquet_dtype Int64
  chunking_matches True
  ok: the default read turns Namibia's NA into a real missing value
  ok: keep_default_na=False keeps the literal string 'NA'
  ok: the default read silently drops the leading zeros: '00123' becomes 123
  ok: dtype={'id': 'str'} preserves the leading zeros exactly
  ok: int64 preserves an ID past 2**53 exactly
  ok: a float64 round-trip corrupts that ID by exactly 1
  ok: a nullable Int64 column survives a CSV round-trip as float64, not Int64
  ok: the same column survives a Parquet round-trip as Int64, unchanged
  ok: an aggregate computed chunk-by-chunk (chunksize=97) equals the whole-file aggregate

5. Prove the harness can fail, then restore it
  ok: a deliberately wrong assertion makes 03_precision_loss.py exit non-zero
  ok: the broken run reports a FAIL line
  ok: the script is restored and exits 0 again
  ok: the restored script reports every assertion held

6. Nothing left behind, and no network dependency baked into the lab
  ok: no URL appears in examples/ or starter/
  ok: no __pycache__ or .pytest_cache directories were left behind
  ok: no .csv, .parquet or .db file was left behind anywhere in the lab

42 checks, 0 failure(s).

Source files

examples/01_the_namibia_trap.py (2215 bytes)
"""Exercise 1 -- the Namibia trap: read_csv() as an inference engine.

Run: python3 01_the_namibia_trap.py

pandas' default na_values list includes the literal string "NA". A country
code column containing "NA" for Namibia is read, by default, as a MISSING
value -- not an error, not a warning, just a quiet substitution. The country
does not disappear loudly; every downstream count of "missing" data is now
wrong in a way no test catches unless you already knew to look for it.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex01-"))
try:
    csv_path = tmpdir / "country_codes.csv"
    csv_path.write_text("code,country\nNA,Namibia\nUS,United States\nFR,France\n")

    default = pd.read_csv(csv_path)
    print("default read:")
    print(default)

    kept = pd.read_csv(csv_path, keep_default_na=False)
    print("\nkeep_default_na=False:")
    print(kept)

    check(
        "the default read turns Namibia's 'NA' into a real missing value",
        pd.isna(default.loc[0, "code"]),
    )
    check(
        "the default read leaves the OTHER two codes untouched",
        default.loc[1, "code"] == "US" and default.loc[2, "code"] == "FR",
    )
    check(
        "keep_default_na=False keeps 'NA' as the literal string",
        kept.loc[0, "code"] == "NA" and isinstance(kept.loc[0, "code"], str),
    )
    check(
        "keep_default_na=False does not turn anything else into a string it wasn't",
        kept.loc[1, "code"] == "US" and kept.loc[2, "code"] == "FR",
    )
    # The row count is identical either way -- the row never disappeared,
    # only the value inside one cell of it silently changed meaning.
    check("both reads keep all three rows", len(default) == 3 and len(kept) == 3)
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("01_the_namibia_trap.py: every assertion held.")
examples/02_leading_zeros.py (2031 bytes)
"""Exercise 2 -- leading zeros: an identifier column silently becomes a number.

Run: python3 02_leading_zeros.py

An identifier column like "00123" LOOKS like text -- it has meaningful
leading zeros, the way a ZIP code, an account number or a barcode does. By
default read_csv() infers it as int64, because every character in it is a
digit, and the leading zeros are simply gone: "00123" becomes 123. Nothing
raises. The join against another system that still has "00123" fails
silently, one row at a time, with no exception naming which row.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex02-"))
try:
    csv_path = tmpdir / "customers.csv"
    csv_path.write_text("id,name\n00123,Alice\n00456,Bob\n00789,Carla\n")

    default = pd.read_csv(csv_path)
    print("default read:")
    print(default)
    print(default.dtypes)

    typed = pd.read_csv(csv_path, dtype={"id": "str"})
    print("\ndtype={'id': 'str'}:")
    print(typed)
    print(typed.dtypes)

    check("the default read infers id as int64", default["id"].dtype == "int64")
    check(
        "the default read silently drops the leading zeros: '00123' becomes 123",
        int(default.loc[0, "id"]) == 123,
    )
    check("dtype={'id': 'str'} keeps id as the str dtype", str(typed["id"].dtype) == "str")
    check(
        "dtype={'id': 'str'} preserves the leading zeros exactly",
        typed.loc[0, "id"] == "00123",
    )
    check(
        "the two reads disagree on the very same cell",
        str(default.loc[0, "id"]) != typed.loc[0, "id"],
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("02_leading_zeros.py: every assertion held.")
examples/03_precision_loss.py (2953 bytes)
"""Exercise 3 -- precision: an ID above 2**53 cannot survive a trip through float64.

Run: python3 03_precision_loss.py

read_csv() infers a purely-numeric column as int64, which represents every
integer in this example exactly. The corruption happens one step LATER, the
moment anything casts that column to float64 -- a join, an arithmetic
operation, a naive "convert everything numeric to float" cleanup step.
float64 has a 53-bit mantissa: it can represent every integer up to 2**53
exactly, and past that boundary it silently rounds to the nearest value it
CAN represent, with no error and no warning.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


BIG_ID = 2**53 + 1  # 9007199254740993 -- one past the exact-integer boundary
print(f"2**53 + 1 = {BIG_ID}")

tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex03-"))
try:
    csv_path = tmpdir / "orders.csv"
    csv_path.write_text(f"order_id\n{BIG_ID}\n")

    df = pd.read_csv(csv_path)
    print("\nread_csv() infers:")
    print(df.dtypes)
    read_value = int(df.loc[0, "order_id"])
    print("value as read:", read_value)

    check("read_csv() infers the column as int64", df["order_id"].dtype == "int64")
    check("int64 preserves the ID exactly, digit for digit", read_value == BIG_ID)

    promoted = df.astype({"order_id": "float64"})
    promoted_value = int(promoted.loc[0, "order_id"])
    print("\nafter .astype('float64'):")
    print(promoted.dtypes)
    print("value after float64 round-trip:", promoted_value)
    print(f"exact:    {BIG_ID}")
    print(f"corrupted:{promoted_value}")

    check(
        "the float64 round-trip silently changes the value",
        promoted_value != BIG_ID,
    )
    check(
        "the corrupted value is exactly one less than the true ID -- the last digit collapsed",
        BIG_ID - promoted_value == 1,
    )
    # Show the exact differing digits, not just "it's wrong".
    exact_str = str(BIG_ID)
    corrupt_str = str(promoted_value)
    check(
        "both numbers have the same number of digits (16)",
        len(exact_str) == len(corrupt_str) == 16,
    )
    first_diff = next(i for i, (a, b) in enumerate(zip(exact_str, corrupt_str)) if a != b)
    print(f"\nfirst differing digit is at position {first_diff}: "
          f"'{exact_str[first_diff]}' (exact) vs '{corrupt_str[first_diff]}' (corrupted)")
    check(
        "the two numbers agree on every digit except the very last one",
        exact_str[:-1] == corrupt_str[:-1] and exact_str[-1] != corrupt_str[-1],
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("03_precision_loss.py: every assertion held.")
examples/04_dates.py (2661 bytes)
"""Exercise 4 -- dates: what parse_dates changes, and why a string date lies.

Run: python3 04_dates.py

Without parse_dates, a date column is just text -- the pandas-3.0 str
dtype, sorted the way any string sorts: character by character. That looks
fine as long as every date is written in the same fixed-width format. The
moment one row is written without a leading zero, the string sort silently
puts it in the wrong place, and nothing about the column's dtype warns you
this could happen.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex04-"))
try:
    csv_path = tmpdir / "events.csv"
    csv_path.write_text("event,date\nfirst,2024-01-05\nsecond,2024-01-20\nthird,2024-1-9\n")

    unparsed = pd.read_csv(csv_path)
    print("without parse_dates:")
    print(unparsed)
    print(unparsed.dtypes)

    parsed = pd.read_csv(csv_path, parse_dates=["date"])
    print("\nwith parse_dates=['date']:")
    print(parsed)
    print(parsed.dtypes)

    check("without parse_dates the column is the str dtype", str(unparsed["date"].dtype) == "str")
    check(
        "with parse_dates the column becomes a real datetime64 dtype",
        str(parsed["date"].dtype).startswith("datetime64"),
    )

    string_sorted = unparsed.sort_values("date")["event"].tolist()
    parsed_sorted = parsed.sort_values("date")["event"].tolist()
    print("\nstring-sorted event order:  ", string_sorted)
    print("parsed-datetime event order:", parsed_sorted)

    # "third" is 2024-01-09 -- chronologically between "first" (Jan 5) and
    # "second" (Jan 20). Written without a leading zero as "2024-1-9", it
    # sorts as a STRING after "2024-01-20", because the character '1' (from
    # "2024-1-9") is greater than '0' (from "2024-01-...") at that position.
    check(
        "the true chronological order is first, third, second",
        parsed_sorted == ["first", "third", "second"],
    )
    check(
        "the raw string sort gets this WRONG -- it puts 'third' last",
        string_sorted == ["first", "second", "third"],
    )
    check(
        "the two sort orders genuinely disagree, proving the string sort is unsafe",
        string_sorted != parsed_sorted,
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("04_dates.py: every assertion held.")
examples/05_encoding.py (2889 bytes)
"""Exercise 5 -- encoding: pandas assumes UTF-8, and a mismatch fails loudly.

Run: python3 05_encoding.py

read_csv()'s `encoding` parameter defaults to UTF-8. A file actually
written in a different encoding -- latin-1 (ISO-8859-1) is common in older
exports from Windows and legacy databases -- either raises a
UnicodeDecodeError on the first byte sequence it cannot interpret as valid
UTF-8, or, when the byte sequence HAPPENS to also be valid (but different)
UTF-8, decodes into visibly wrong characters ("mojibake") with no error at
all. Only naming the correct encoding round-trips the text exactly.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


ORIGINAL_NAME = "José"  # "José"
ORIGINAL_CITY = "São Paulo"  # "São Paulo"

tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex05-"))
try:
    csv_path = tmpdir / "customers_latin1.csv"
    # Written deliberately in latin-1, the encoding this file actually is.
    csv_path.write_text(f"name,city\n{ORIGINAL_NAME},{ORIGINAL_CITY}\n", encoding="latin-1")

    error_class = None
    error_message = ""
    try:
        pd.read_csv(csv_path, encoding="utf-8")
    except UnicodeDecodeError as exc:
        error_class = type(exc).__name__
        error_message = str(exc)
    print(f"reading with encoding='utf-8' (WRONG for this file): {error_class}: {error_message}")

    check(
        "reading a latin-1 file as UTF-8 raises UnicodeDecodeError -- it does not silently succeed",
        error_class == "UnicodeDecodeError",
    )

    correct = pd.read_csv(csv_path, encoding="latin-1")
    print("\nreading with encoding='latin-1' (correct for this file):")
    print(correct)

    check(
        "reading with the correct encoding recovers the accented name exactly",
        correct.loc[0, "name"] == ORIGINAL_NAME,
    )
    check(
        "reading with the correct encoding recovers the accented city exactly",
        correct.loc[0, "city"] == ORIGINAL_CITY,
    )

    # pandas' default (no encoding= given) is UTF-8, and fails the same way
    # on this file -- confirming the parameter's documented default rather
    # than merely asserting it.
    default_error_class = None
    try:
        pd.read_csv(csv_path)
    except UnicodeDecodeError as exc:
        default_error_class = type(exc).__name__
    check(
        "the undecorated default (no encoding= argument) behaves identically to encoding='utf-8'",
        default_error_class == "UnicodeDecodeError",
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("05_encoding.py: every assertion held.")
examples/06_chunking.py (2416 bytes)
"""Exercise 6 -- reading in chunks: a file larger than memory, one piece at a time.

Run: python3 06_chunking.py

`chunksize` turns read_csv() into an iterator of DataFrames instead of one
big DataFrame, so you can process a file far larger than available memory
by never holding more than one chunk of it at once. The claim this exercise
proves: an aggregate computed chunk-by-chunk must equal the whole-file
answer EXACTLY -- chunking changes how the data arrives, never what it
adds up to.
"""

import tempfile
from pathlib import Path

import numpy as np
import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex06-"))
try:
    csv_path = tmpdir / "readings.csv"
    rng = np.random.default_rng(42)
    n_rows = 50_000
    values = rng.integers(1, 1000, size=n_rows)
    pd.DataFrame({"value": values}).to_csv(csv_path, index=False)
    print(f"wrote {n_rows} rows to a temporary CSV")

    whole_sum = int(pd.read_csv(csv_path)["value"].sum())
    print("whole-file sum:", whole_sum)

    chunk_sum = 0
    n_chunks = 0
    n_rows_seen = 0
    for chunk in pd.read_csv(csv_path, chunksize=1_000):
        chunk_sum += int(chunk["value"].sum())
        n_rows_seen += len(chunk)
        n_chunks += 1
    print(f"chunked sum ({n_chunks} chunks of up to 1,000 rows): {chunk_sum}")

    check("chunking visits every row exactly once", n_rows_seen == n_rows)
    check("chunking produces the expected number of chunks", n_chunks == 50)
    check(
        "the chunk-by-chunk sum equals the whole-file sum exactly",
        chunk_sum == whole_sum,
    )

    # A second aggregate, to show it's not a coincidence of sum() specifically:
    # the row count is also exact when accumulated chunk by chunk.
    counted = 0
    for chunk in pd.read_csv(csv_path, chunksize=777):  # a chunk size that does not divide evenly
        counted += len(chunk)
    check(
        "row count accumulated over odd-sized chunks (777) still matches exactly",
        counted == n_rows,
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("06_chunking.py: every assertion held.")
examples/07_csv_vs_parquet.py (3581 bytes)
"""Exercise 7 -- the day's headline claim: CSV loses dtypes, Parquet keeps them.

Run: python3 07_csv_vs_parquet.py

CSV is plain text. Every value that goes into a CSV file becomes a
character string on disk, and every value that comes back out is
RE-INFERRED from scratch by read_csv()'s type-inference engine -- the same
engine exercises 1-4 spent proving is not infallible. Parquet is a typed,
columnar binary format: the dtype of every column is written into the file
itself, so reading it back is a lookup, not a guess. This exercise writes
the same DataFrame -- including a nullable Int64 column with a genuine
missing value -- through both formats and compares the dtypes side by side.
"""

import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


original = pd.DataFrame(
    {
        "order_id": pd.array([1001, 1002, pd.NA], dtype="Int64"),
        "price": pd.array([19.99, 44.50, 7.25], dtype="float64"),
        "in_stock": pd.array([True, False, True], dtype="bool"),
        "category": pd.array(["books", "tools", "books"], dtype="str"),
    }
)
print("original dtypes:")
print(original.dtypes)

tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex07-"))
try:
    csv_path = tmpdir / "orders.csv"
    original.to_csv(csv_path, index=False)
    round_tripped_csv = pd.read_csv(csv_path)
    print("\ndtypes after a CSV round-trip:")
    print(round_tripped_csv.dtypes)
    print(round_tripped_csv)

    pq_path = tmpdir / "orders.parquet"
    original.to_parquet(pq_path)
    round_tripped_parquet = pd.read_parquet(pq_path)
    print("\ndtypes after a Parquet round-trip:")
    print(round_tripped_parquet.dtypes)
    print(round_tripped_parquet)

    csv_changed = [
        col for col in original.columns
        if str(original[col].dtype) != str(round_tripped_csv[col].dtype)
    ]
    parquet_changed = [
        col for col in original.columns
        if str(original[col].dtype) != str(round_tripped_parquet[col].dtype)
    ]
    print("\ncolumns whose dtype changed via CSV:    ", csv_changed)
    print("columns whose dtype changed via Parquet:", parquet_changed)

    check(
        "the CSV round-trip changes at least one column's dtype",
        len(csv_changed) >= 1,
    )
    check(
        "specifically, the nullable Int64 order_id column is not what it started as after CSV",
        str(round_tripped_csv["order_id"].dtype) != "Int64",
    )
    check(
        "the missing order_id survives the CSV round-trip only as a float NaN, not pd.NA",
        round_tripped_csv["order_id"].isna().sum() == 1
        and str(round_tripped_csv["order_id"].dtype) == "float64",
    )
    check(
        "the Parquet round-trip preserves every column's dtype EXACTLY",
        parquet_changed == [],
    )
    check(
        "the Parquet round-trip keeps order_id as the nullable Int64 dtype, missing value and all",
        str(round_tripped_parquet["order_id"].dtype) == "Int64"
        and round_tripped_parquet["order_id"].isna().sum() == 1,
    )
    check(
        "the Parquet round-trip's actual values equal the originals exactly, not just the dtypes",
        round_tripped_parquet.equals(original),
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("07_csv_vs_parquet.py: every assertion held.")
examples/08_inspection_battery.py (3546 bytes)
"""Exercise 8 -- the inspection battery you run on any unfamiliar frame.

Run: python3 08_inspection_battery.py

In order: .head(), .info(), .dtypes, .describe(), .isna().sum(),
.nunique(), .value_counts(), memory_usage(deep=True). Each answers a
different question about data you have not seen before, and running all
eight, in this order, costs seconds and catches most of the silent
failures the earlier exercises demonstrate one at a time. This exercise
builds a frame with KNOWN properties -- an exact missing-value count, an
exact number of distinct values per column, and one column whose most
common value is known in advance -- and checks the battery's answers
against those known values.
"""

import io

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


df = pd.DataFrame(
    {
        "region": ["north", "south", "north", "east", "north", "south", None, "north"],
        "amount": [10, 20, None, 15, 30, 20, 25, 10],
    }
)
print("the frame under inspection:")
print(df)

# 1. .head() -- the fastest sanity check that the data loaded the way you expected.
print("\n1. .head(3):")
print(df.head(3))
check(".head(3) returns exactly 3 rows", len(df.head(3)) == 3)

# 2. .info() -- row count, columns, non-null counts and dtypes in one block.
print("\n2. .info():")
buf = io.StringIO()
df.info(buf=buf)
info_text = buf.getvalue()
print(info_text)
check(".info() reports the correct row count (8 entries)", "8 entries" in info_text)
check(".info() names both columns", "region" in info_text and "amount" in info_text)

# 3. .dtypes -- what kind of thing is actually in each column.
print("3. .dtypes:")
print(df.dtypes)
check("region is the pandas-3.0 str dtype", str(df["region"].dtype) == "str")
check("amount, with a None in it, is float64 (int64 has no missing-value slot)", df["amount"].dtype == "float64")

# 4. .describe() -- summary statistics for the numeric columns.
print("\n4. .describe():")
desc = df.describe()
print(desc)
check(".describe() counts only the 7 non-missing amount values", desc.loc["count", "amount"] == 7.0)

# 5. .isna().sum() -- exactly how much is missing, per column.
print("\n5. .isna().sum():")
na_counts = df.isna().sum()
print(na_counts)
check("region has exactly 1 missing value", na_counts["region"] == 1)
check("amount has exactly 1 missing value", na_counts["amount"] == 1)

# 6. .nunique() -- how many distinct values, per column (missing values not counted).
print("\n6. .nunique():")
uniq = df.nunique()
print(uniq)
check("region has exactly 3 distinct non-missing values (north, south, east)", uniq["region"] == 3)
check("amount has exactly 5 distinct non-missing values (10, 20, 15, 30, 25)", uniq["amount"] == 5)

# 7. .value_counts() -- the actual distribution, most common first.
print("\n7. region.value_counts():")
vc = df["region"].value_counts()
print(vc)
check("the single most common region is 'north'", vc.index[0] == "north")
check("'north' appears exactly 4 times", vc.iloc[0] == 4)

# 8. memory_usage(deep=True) -- the real byte cost, including string storage.
print("\n8. memory_usage(deep=True):")
mem = df.memory_usage(deep=True)
print(mem)
check("memory_usage(deep=True) reports a positive byte count for every column", (mem > 0).all())

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("08_inspection_battery.py: every assertion held.")
examples/09_category_memory.py (2200 bytes)
"""Exercise 9 -- category memory: converting a low-cardinality string column.

Run: python3 09_category_memory.py

A "category" dtype stores each distinct value ONCE and represents every row
as a small integer code pointing at it. For a column with few distinct
values repeated many times -- region names, status flags, product
categories -- that trades a large amount of repeated string storage for a
small lookup table plus one integer per row. This exercise measures the
actual reduction on the authoring machine and asserts the RATIO clears a
stated factor, never a byte count, because byte counts are a fact about one
machine's malloc behaviour on one day.
"""

import numpy as np
import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


rng = np.random.default_rng(42)
n_rows = 20_000
regions = rng.choice(["north", "south", "east", "west"], size=n_rows)
df = pd.DataFrame({"region": regions})
print(f"{n_rows} rows, {df['region'].nunique()} distinct region values")
print("region dtype:", df["region"].dtype)

mem_str = int(df["region"].memory_usage(deep=True))
df["region_cat"] = df["region"].astype("category")
mem_cat = int(df["region_cat"].memory_usage(deep=True))

print(f"\nmemory_usage(deep=True) as {df['region'].dtype}:  {mem_str:>8,} bytes")
print(f"memory_usage(deep=True) as category:          {mem_cat:>8,} bytes")
ratio = mem_str / mem_cat
print(f"reduction ratio: {ratio:.2f}x")

check("the category dtype has the same 4 distinct values as the original", df["region_cat"].nunique() == 4)
check(
    "the category column is genuinely smaller than the string column",
    mem_cat < mem_str,
)
check(
    "the reduction ratio clears at least 5x on this 4-category, 20,000-row column",
    ratio >= 5.0,
)
check(
    "converting to category does not change the actual values, only the storage",
    (df["region_cat"].astype("str") == df["region"]).all(),
)

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("09_category_memory.py: every assertion held.")
examples/10_other_formats.py (3173 bytes)
"""Supplementary -- JSON, SQL (sqlite3) and the stdlib csv module.

Run: python3 10_other_formats.py

Not one of the nine graded exercises -- this script exists to back the
lesson's Tools section with real, captured output for the formats it
covers beyond CSV and Parquet. openpyxl (Excel) is deliberately NOT run
here because it is not installed in this environment; the lesson describes
it from documentation only and says so plainly.
"""

import csv
import io
import json
import sqlite3
import tempfile
from pathlib import Path

import pandas as pd

checks = 0
failures = 0


def check(label, condition):
    global checks, failures
    checks += 1
    if condition:
        print(f"  ok: {label}")
    else:
        print(f"  FAIL: {label}")
        failures += 1


tmpdir = Path(tempfile.mkdtemp(prefix="d121-ex10-"))
try:
    # --- JSON ---------------------------------------------------------
    records = [
        {"id": 1, "name": "Alice", "active": True},
        {"id": 2, "name": "Bob", "active": False},
    ]
    json_path = tmpdir / "users.json"
    json_path.write_text(json.dumps(records))
    df_json = pd.read_json(json_path)
    print("read_json() on a list of records:")
    print(df_json)
    print(df_json.dtypes)
    check("read_json() recovers all 2 records", len(df_json) == 2)
    check("read_json() infers 'active' as a real boolean column", df_json["active"].dtype == "bool")

    # --- sqlite3 --------------------------------------------------------
    db_path = tmpdir / "orders.db"
    conn = sqlite3.connect(db_path)
    orders = pd.DataFrame({"order_id": [1, 2, 3], "amount": [19.99, 44.50, 7.25]})
    orders.to_sql("orders", conn, index=False, if_exists="replace")
    from_sql = pd.read_sql("SELECT * FROM orders WHERE amount > 10", conn)
    print("\nread_sql() against a real sqlite3 connection:")
    print(from_sql)
    check("read_sql()'s WHERE filter runs inside the database, not in pandas", len(from_sql) == 2)
    check("read_sql() returns the correct rows", set(from_sql["order_id"]) == {1, 2})
    conn.close()

    # --- stdlib csv module ------------------------------------------------
    # Where the stdlib module beats pandas: streaming one row at a time with
    # NO type inference at all -- every field arrives as exactly the string
    # that was in the file, which is sometimes exactly what you want.
    csv_path = tmpdir / "raw.csv"
    csv_path.write_text("id,code\n001,00A\n002,00B\n")
    rows = []
    with open(csv_path, newline="") as fh:
        reader = csv.DictReader(fh)
        for row in reader:
            rows.append(row)
    print("\nstdlib csv.DictReader (no type inference at all):")
    print(rows)
    check("csv.DictReader keeps every field as a plain str, leading zeros included", rows[0]["id"] == "001")
    check(
        "read_csv(), by contrast, would infer 'id' as an integer and drop those zeros",
        int(pd.read_csv(csv_path)["id"].iloc[0]) == 1,
    )
finally:
    for f in tmpdir.iterdir():
        f.unlink()
    tmpdir.rmdir()

print(f"\n{checks} checks, {failures} failure(s).")
if failures:
    raise SystemExit(1)
print("10_other_formats.py: every assertion held.")
metadata.yml (4298 bytes)
lesson_id: D121
day: 121
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-121-loading-and-inspecting-data
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import pandas; print(pandas.__version__)"
run_commands:
  - 'cd examples && ../.venv/bin/python3 01_the_namibia_trap.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 02_leading_zeros.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 03_precision_loss.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 04_dates.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 05_encoding.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 06_chunking.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 07_csv_vs_parquet.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 08_inspection_battery.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 09_category_memory.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 10_other_formats.py && cd ..'
  - .venv/bin/python3 starter/check_progress.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 40
last_executed: '2026-08-19'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, numpy 2.5.2, bash 3.2.57 -- bash tests/run_tests.sh -> 42 checks, 0 failure(s), exit 0. All ten reference scripts in examples/ (nine graded exercises plus 10_other_formats.py, a supplementary script demonstrating JSON, sqlite3 and the stdlib csv module for the lessons Tools section) exit 0 with every internal assertion holding, run individually and via the harness. starter/check_progress.py reports 0 of 9 exercises complete on the untouched checkout (exit 1), and the harness separately solves every blank in a scratch copy and confirms 9 of 9 complete with exit 0, without modifying the real starter/exercises.py on disk. Section 5 of the harness deliberately breaks the assertion inside 03_precision_loss.py (flips read_value == BIG_ID to !=), confirms the run exits non-zero with a printed FAIL line, restores the file, and confirms it passes again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Section 6 confirms no .csv, .parquet or .db file and no __pycache__ directory is left anywhere in the lab after a full run. Everything was run through a real lab-local .venv created by the documented setup commands. Four honesty notes from this run. FIRST: openpyxl, matplotlib, scipy and polars are not installed in this environment; the lessons Tools section describes openpyxl (Excel) and polars scan_csv from their public documentation only, and says so plainly -- no output attributed to either is reproduced anywhere. SECOND: exercise 5s encoding trap produced a hard UnicodeDecodeError on this run rather than silent mojibake, because the specific latin-1 byte for the accented e in Jose (0xE9) is not a valid UTF-8 continuation byte in that position; the lesson states both possible failure modes honestly and reports which one this specific example actually produced, rather than claiming mojibake occurred when it did not. THIRD: exercise 9s category-memory reduction measured 12.40x on this run (250,204 bytes as the str dtype versus 20,183 bytes as category, on a seeded 20,000-row, 4-category column); the lab asserts the ratio clears 5x, a bar comfortably below what this run measured, and expected-output/FIELDS.md records the exact byte counts as machine-specific while the ratio claim is the portable one. FOURTH: exercise 7s CSV-vs-Parquet comparison uses a column with a genuine missing value (pd.NA in a nullable Int64 column) specifically because that is where the two formats diverge most sharply -- the CSV round-trip promotes the column to float64 and prints the surviving IDs with a trailing .0, while Parquet preserves the exact Int64 dtype and the pd.NA marker; both are captured directly from this run, not assumed from documentation.'
requirements/README.md (3680 bytes)
# What is installed, why, and what it costs

Three packages, all free and open source, installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `pandas` | 3.0.5 | BSD 3-Clause | Every `read_csv`, `read_json`, `read_sql`, `read_parquet`, `to_csv` and `to_parquet` call in this lab. Pinned exactly because this day's captured output — dtypes, error messages, the `str` default — is version-specific. |
| `pyarrow` | 25.0.1 | Apache 2.0 | The engine behind `to_parquet()` / `read_parquet()` (exercise 7) and the storage backing pandas 3.0's default `str` dtype and `Int64` nullable arrays. |
| `numpy` | 2.5.2 | BSD 3-Clause | The random number generator behind the synthetic columns in exercises 6 and 9. |

`sqlite3`, `csv`, `json` and `io`, used in exercise 10 and the inspection
battery, are all standard library — nothing extra to install for them.

There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.

## Why the versions are pinned exactly, not just floored

This lab's central claims are pandas-3.0-specific and would print
different, equally-correct values on pandas 2.x:

- A plain integer column of digits like `"00123"` is inferred as `int64`
  here; the exact inference rules for `dtype=` overrides have not changed
  across recent pandas majors, but the default string dtype it prints when
  you check `.dtypes` elsewhere in this lab (`str` versus `object`) has.
- The CSV-versus-Parquet dtype comparison in exercise 7 depends on the
  nullable `Int64` dtype's exact round-trip behaviour through
  `to_csv()`/`read_csv()`, which is consistent across recent pandas
  releases but is asserted here against the specific 3.0.5 install this
  lab was authored and captured on.
- `pd.Series(["a", "b"]).dtype` prints `str` on 3.0.5 (used throughout the
  inspection battery in exercise 8); it prints `object` on any pandas
  release before 3.0.

Running this lab's suite against a different pandas major version may
produce differences that are not bugs — `expected-output/FIELDS.md` states
precisely which values are version-specific and which are not.

## The one time the network is needed

```bash
.venv/bin/pip install -r requirements/requirements.txt
```

That is the only command in the lab that opens a connection. Every script
this lab runs writes its own tiny CSV, JSON, SQLite database or Parquet
file into a temporary directory it created itself and deletes before
returning — nothing is downloaded, and no external dataset is fetched.

## What is deliberately *not* installed

**openpyxl**, **matplotlib**, **scipy** and **polars** are not installed in
this environment. The lesson's Tools section describes Excel support via
`openpyxl` from pandas' own documentation, since it is not installed here
— it says so plainly, and no output attributed to `read_excel()` is
reproduced anywhere in this lab or its lesson. polars' lazy `scan_csv` is
covered the same way, from its public documentation, as a design contrast.

## If you cannot install anything at all

pandas is not in the Python standard library, and there is no reduced path
through most of this lab without it. Exercise 10's stdlib-`csv` half is the
one part that would still run on a bare Python installation; everything
else genuinely needs pandas 3.0.5's specific behaviour, which nothing else
on your system will reproduce. If pandas cannot be installed, read the
lesson's captured output and this lab's `expected-output/` directory
instead; every number there came from a real run and is not invented.
requirements/requirements.txt (43 bytes)
pandas==3.0.5
pyarrow==25.0.1
numpy==2.5.2
starter/check_progress.py (2570 bytes)
"""Run from this directory (or `python3 starter/check_progress.py` from the
lab root): reports how many of the nine exercises in exercises.py are
complete and correct, against the same expected values the reference
examples/ scripts assert.

Exit code is 0 only when all nine are correct, matching the convention the
rest of this lab's tests use.
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import exercises as ex

passed = 0
total = 9


def report(number, description, fn, expected_check):
    """Run one exercise function, catching the NameError an unfilled blank
    raises, and check its return value against expected_check(value)."""
    global passed
    try:
        result = fn()
    except NameError as exc:
        print(f"  {number}. {description}: NOT YET COMPLETE ({exc})")
        return
    except Exception as exc:  # noqa: BLE001 -- report any other mistake too
        print(f"  {number}. {description}: ERROR -- {exc!r}")
        return
    try:
        ok = expected_check(result)
    except Exception as exc:  # a malformed return value shouldn't crash the report
        print(f"  {number}. {description}: WRONG    (return value {result!r} raised {exc!r})")
        return
    if ok:
        passed += 1
        print(f"  {number}. {description}: correct  (got {result!r})")
    else:
        print(f"  {number}. {description}: WRONG    (got {result!r})")


report(
    1,
    "the Namibia trap",
    ex.ex01_the_namibia_trap,
    lambda r: (isinstance(r[0], float) and math.isnan(r[0])) and r[1] == "NA",
)
report(
    2,
    "leading zeros",
    ex.ex02_leading_zeros,
    lambda r: r[0] == 123 and r[1] == "00123",
)
report(
    3,
    "precision loss",
    ex.ex03_precision_loss,
    lambda r: r[0] == 2**53 + 1 and r[1] == 2**53,
)
report(
    4,
    "dates",
    ex.ex04_dates,
    lambda r: r[0] == "str" and r[1].startswith("datetime64"),
)
report(
    5,
    "encoding",
    ex.ex05_encoding,
    lambda r: r == "UnicodeDecodeError",
)
report(
    6,
    "chunking",
    ex.ex06_chunking,
    lambda r: r[0] == 28 and r[1] == 28,
)
report(
    7,
    "CSV vs Parquet",
    ex.ex07_csv_vs_parquet,
    lambda r: r[0] != "Int64" and r[1] == "Int64",
)
report(
    8,
    "inspection battery",
    ex.ex08_inspection_battery,
    lambda r: r == (1, "north"),
)
report(
    9,
    "category memory",
    ex.ex09_category_memory,
    lambda r: r[0] > r[1] and (r[0] / r[1]) >= 2.0,
)

print(f"\n{passed} of {total} exercises complete.")
sys.exit(0 if passed == total else 1)
starter/exercises.py (5818 bytes)
"""Day 121 starter -- nine exercises, one function each.

Each function below is a working skeleton: the setup -- including writing
whatever small file the exercise needs into a temporary directory -- is
written for you, and exactly one line is left for you to write, marked
with the sentinel name `_FILL_THIS_IN`. Replace that name with real code.
Leaving it as-is raises a clear NameError when the function runs, which
`check_progress.py` catches and reports -- it does not crash the whole
script.

Every function cleans up the temporary file(s) it wrote before returning,
so running this file, complete or not, leaves nothing behind.

Read `../examples/` for the fully worked reference AFTER you have tried
each one yourself; that is where every one of these ideas is explained in
comments. Run your progress with:

    python3 check_progress.py

from inside this `starter/` directory (or `python3 starter/check_progress.py`
from the lab root).
"""

import tempfile
from pathlib import Path

import pandas as pd


def _scratch_file(name, content, encoding="utf-8"):
    """Write `content` into a fresh temp directory and return its path."""
    tmpdir = Path(tempfile.mkdtemp(prefix="d121-starter-"))
    path = tmpdir / name
    path.write_text(content, encoding=encoding)
    return path


def _cleanup(path):
    path.unlink()
    path.parent.rmdir()


def ex01_the_namibia_trap():
    """Read a country-code CSV containing "NA" for Namibia. Return
    (default_code, kept_code) -- the value read by default, and the value
    read with keep_default_na=False."""
    path = _scratch_file("countries.csv", "code,country\nNA,Namibia\nUS,United States\n")
    try:
        default_df = pd.read_csv(path)
        kept_df = _FILL_THIS_IN  # pd.read_csv(path, keep_default_na=False)
        return default_df.loc[0, "code"], kept_df.loc[0, "code"]
    finally:
        _cleanup(path)


def ex02_leading_zeros():
    """Read an id column of "00123" two ways. Return (default_id, typed_id)."""
    path = _scratch_file("customers.csv", "id,name\n00123,Alice\n")
    try:
        default_df = pd.read_csv(path)
        typed_df = pd.read_csv(path, dtype=_FILL_THIS_IN)  # {'id': 'str'}
        return int(default_df.loc[0, "id"]), typed_df.loc[0, "id"]
    finally:
        _cleanup(path)


def ex03_precision_loss():
    """Read an integer above 2**53 and round-trip it through float64.
    Return (as_int64, as_float64) as plain Python ints."""
    big_id = 2**53 + 1
    path = _scratch_file("orders.csv", f"order_id\n{big_id}\n")
    try:
        df = pd.read_csv(path)
        as_int64 = int(df.loc[0, "order_id"])
        promoted = df.astype({"order_id": _FILL_THIS_IN})  # 'float64'
        as_float64 = int(promoted.loc[0, "order_id"])
        return as_int64, as_float64
    finally:
        _cleanup(path)


def ex04_dates():
    """Read a date column with and without parse_dates. Return
    (unparsed_dtype, parsed_dtype) as strings."""
    path = _scratch_file("events.csv", "event,date\nfirst,2024-01-05\n")
    try:
        unparsed = pd.read_csv(path)
        parsed = pd.read_csv(path, parse_dates=_FILL_THIS_IN)  # ['date']
        return str(unparsed["date"].dtype), str(parsed["date"].dtype)
    finally:
        _cleanup(path)


def ex05_encoding():
    """Write a latin-1 file and read it back with the WRONG encoding.
    Return the name of the exception class raised (a string), or None if
    nothing was raised."""
    path = _scratch_file("name.csv", "name\nJosé\n", encoding="latin-1")
    try:
        try:
            pd.read_csv(path, encoding=_FILL_THIS_IN)  # 'utf-8'
            return None
        except UnicodeDecodeError as exc:
            return type(exc).__name__
    finally:
        _cleanup(path)


def ex06_chunking():
    """Sum a column two ways: all at once, and via chunksize=3. Return
    (whole_sum, chunked_sum) as plain ints."""
    path = _scratch_file("readings.csv", "value\n1\n2\n3\n4\n5\n6\n7\n")
    try:
        whole_sum = int(pd.read_csv(path)["value"].sum())
        chunked_sum = 0
        for chunk in pd.read_csv(path, chunksize=_FILL_THIS_IN):  # 3
            chunked_sum += int(chunk["value"].sum())
        return whole_sum, chunked_sum
    finally:
        _cleanup(path)


def ex07_csv_vs_parquet():
    """Round-trip a nullable-Int64 column through CSV and through Parquet.
    Return (csv_dtype, parquet_dtype) as strings."""
    tmpdir = Path(tempfile.mkdtemp(prefix="d121-starter-"))
    df = pd.DataFrame({"order_id": pd.array([1001, 1002, pd.NA], dtype="Int64")})
    csv_path = tmpdir / "orders.csv"
    pq_path = tmpdir / "orders.parquet"
    try:
        df.to_csv(csv_path, index=False)
        df.to_parquet(pq_path)
        csv_dtype = str(pd.read_csv(csv_path)["order_id"].dtype)
        parquet_dtype = str(_FILL_THIS_IN["order_id"].dtype)  # pd.read_parquet(pq_path)
        return csv_dtype, parquet_dtype
    finally:
        csv_path.unlink()
        pq_path.unlink()
        tmpdir.rmdir()


def ex08_inspection_battery():
    """On a frame with a known missing value and a known most-common
    region, return (na_count_for_region, top_region) using .isna().sum()
    and .value_counts()."""
    df = pd.DataFrame({"region": ["north", "north", "south", None, "north"]})
    na_count = df["region"].isna().sum()
    top_region = _FILL_THIS_IN  # df["region"].value_counts().index[0]
    return int(na_count), top_region


def ex09_category_memory():
    """Convert a string column to category and return (str_bytes,
    category_bytes) from memory_usage(deep=True), as plain ints."""
    df = pd.DataFrame({"region": ["north", "south"] * 500})
    str_bytes = int(df["region"].memory_usage(deep=True))
    cat_bytes = int(df["region"].astype(_FILL_THIS_IN).memory_usage(deep=True))  # "category"
    return str_bytes, cat_bytes
tests/run_tests.sh (15381 bytes)
#!/usr/bin/env bash
# Tests for the Day 121 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * a country-code CSV containing "NA" for Namibia is read, by default,
#     as a MISSING value -- keep_default_na=False keeps it as the string
#     "NA" instead;
#   * an id column "00123" is inferred as the integer 123 by default, and
#     dtype={"id": "str"} preserves the leading zeros exactly;
#   * an integer above 2**53 survives read_csv()'s int64 inference exactly,
#     but a float64 round-trip silently corrupts its last digit;
#   * a date column left unparsed sorts lexicographically -- and gets the
#     chronological order WRONG the moment one row drops a leading zero --
#     while parse_dates=[...] sorts correctly;
#   * reading a latin-1 file with encoding="utf-8" raises a real
#     UnicodeDecodeError, and only the correct encoding round-trips exactly;
#   * an aggregate computed chunk-by-chunk (chunksize=1000, then again with
#     an odd chunksize=777) equals the whole-file aggregate exactly;
#   * a CSV round-trip changes at least one dtype (a nullable Int64 column
#     becomes float64), while a Parquet round-trip preserves every dtype,
#     and every value, exactly;
#   * the eight-command inspection battery (.head, .info, .dtypes,
#     .describe, .isna().sum(), .nunique(), .value_counts(),
#     memory_usage(deep=True)) reports exact, independently-known values on
#     a frame built for this test;
#   * converting a low-cardinality string column to category reduces
#     memory_usage(deep=True) by at least 5x, asserted as a ratio;
#   * JSON, SQL (sqlite3) and the stdlib csv module are also demonstrated
#     end to end;
#   * nothing is left behind on disk -- no stray .csv, .parquet or .db file,
#     no __pycache__.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside a temporary directory each script creates and
# removes itself. Deterministic, non-interactive, exits 0 only if every
# check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Bytecode left by an EARLIER command is not this run's litter. `.venv` is
# untouched, because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

check_eq() {
  # check_eq <label> <expected> <actual>
  if [ "$2" = "$3" ]; then
    check "$1" "yes"
  else
    check "$1 (expected [$2], got [$3])" "no"
  fi
}

# Resolve python: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

python_bin="$(resolve_tool python3 "${PYTHON:-}")" || {
  echo "FAIL: python3 not found." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing python3:" >&2
  echo "    PYTHON=/path/to/python3 bash tests/run_tests.sh" >&2
  exit 1
}

if ! "${python_bin}" -c "import pandas, pyarrow" >/dev/null 2>&1; then
  echo "FAIL: pandas/pyarrow are not importable from ${python_bin}." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 121 — Read It Right"
echo

# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------

versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version

print(f"python   {platform.python_version()}")
for name in ("pandas", "pyarrow", "numpy"):
    print(f"{name:<8} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe      {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/  /'

pinned_pandas="$(grep -E '^pandas==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_pandas="$("${python_bin}" -c "from importlib.metadata import version; print(version('pandas'))")"
check_eq "installed pandas matches requirements.txt" "${pinned_pandas}" "${installed_pandas}"

pandas_major="$("${python_bin}" -c "import pandas; print(pandas.__version__.split('.')[0])")"
check_eq "pandas is version 3 or later (this lab's captured output is 3.0.5-specific)" "3" "${pandas_major}"

# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------

for script in 01_the_namibia_trap 02_leading_zeros 03_precision_loss 04_dates \
              05_encoding 06_chunking 07_csv_vs_parquet 08_inspection_battery \
              09_category_memory 10_other_formats; do
  out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
  status=$?
  if [ "${status}" -ne 0 ]; then
    check "${script}.py exits 0" "no"
    echo "${out}" | tail -8 | sed 's/^/      /'
  else
    check "${script}.py exits 0" "yes"
  fi
  case "${out}" in
    *"${script}.py: every assertion held."*)
      check "${script}.py reports every assertion held" "yes" ;;
    *) check "${script}.py reports every assertion held" "no" ;;
  esac
done

# --------------------------------------------------------------------------
echo
echo "3. The starter checker: honest progress, both directions"
# --------------------------------------------------------------------------

starter_out="$(cd "${lab_dir}" && "${python_bin}" starter/check_progress.py 2>&1)"
starter_status=$?
echo "${starter_out}" | tail -3 | sed 's/^/  /'
case "${starter_out}" in
  *"0 of 9 exercises complete."*)
    check "an untouched starter checkout reports 0 of 9 complete" "yes" ;;
  *)
    check "an untouched starter checkout reports 0 of 9 complete" "no" ;;
esac
if [ "${starter_status}" -ne 0 ]; then
  check "the starter checker exits non-zero when incomplete" "yes"
else
  check "the starter checker exits non-zero when incomplete" "no"
fi

# Prove the checker can also report success: solve every exercise in a
# scratch copy, confirm 9 of 9 and exit 0, then discard the copy. The real
# starter/exercises.py on disk is never modified by this.
solved_dir="$(mktemp -d)"
trap 'rm -rf "${solved_dir}"' EXIT
cp "${lab_dir}/starter/exercises.py" "${solved_dir}/exercises.py"
cp "${lab_dir}/starter/check_progress.py" "${solved_dir}/check_progress.py"
"${python_bin}" - "${solved_dir}/exercises.py" <<'PY'
import sys
path = sys.argv[1]
s = open(path).read()
replacements = [
    ('kept_df = _FILL_THIS_IN  # pd.read_csv(path, keep_default_na=False)',
     'kept_df = pd.read_csv(path, keep_default_na=False)'),
    ('typed_df = pd.read_csv(path, dtype=_FILL_THIS_IN)  # {\'id\': \'str\'}',
     'typed_df = pd.read_csv(path, dtype={\'id\': \'str\'})'),
    ('promoted = df.astype({"order_id": _FILL_THIS_IN})  # \'float64\'',
     'promoted = df.astype({"order_id": \'float64\'})'),
    ('parsed = pd.read_csv(path, parse_dates=_FILL_THIS_IN)  # [\'date\']',
     'parsed = pd.read_csv(path, parse_dates=[\'date\'])'),
    ('pd.read_csv(path, encoding=_FILL_THIS_IN)  # \'utf-8\'',
     'pd.read_csv(path, encoding=\'utf-8\')'),
    ('for chunk in pd.read_csv(path, chunksize=_FILL_THIS_IN):  # 3',
     'for chunk in pd.read_csv(path, chunksize=3):'),
    ('parquet_dtype = str(_FILL_THIS_IN["order_id"].dtype)  # pd.read_parquet(pq_path)',
     'parquet_dtype = str(pd.read_parquet(pq_path)["order_id"].dtype)'),
    ('top_region = _FILL_THIS_IN  # df["region"].value_counts().index[0]',
     'top_region = df["region"].value_counts().index[0]'),
    ('cat_bytes = int(df["region"].astype(_FILL_THIS_IN).memory_usage(deep=True))  # "category"',
     'cat_bytes = int(df["region"].astype("category").memory_usage(deep=True))'),
]
for old, new in replacements:
    if old not in s:
        raise SystemExit(f"pattern not found, starter/exercises.py has drifted: {old!r}")
    s = s.replace(old, new)
open(path, "w").write(s)
PY
solved_out="$(cd "${solved_dir}" && "${python_bin}" check_progress.py 2>&1)"
solved_status=$?
echo "${solved_out}" | tail -3 | sed 's/^/  /'
case "${solved_out}" in
  *"9 of 9 exercises complete."*)
    check "a fully solved copy reports 9 of 9 complete" "yes" ;;
  *)
    check "a fully solved copy reports 9 of 9 complete" "no" ;;
esac
check_eq "the checker exits 0 once every exercise is correct" "0" "${solved_status}"
rm -rf "${solved_dir}"
trap - EXIT

# --------------------------------------------------------------------------
echo
echo "4. The lesson's sharpest claims, checked one value at a time"
# --------------------------------------------------------------------------

facts="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
import tempfile
from pathlib import Path

import pandas as pd

tmpdir = Path(tempfile.mkdtemp())

# Namibia trap.
p1 = tmpdir / "c.csv"
p1.write_text("code,country\nNA,Namibia\n")
default_na = pd.isna(pd.read_csv(p1).loc[0, "code"])
kept_na = pd.read_csv(p1, keep_default_na=False).loc[0, "code"]
p1.unlink()
print("default_is_missing", default_na)
print("kept_value", kept_na)

# Leading zeros.
p2 = tmpdir / "id.csv"
p2.write_text("id\n00123\n")
default_id = int(pd.read_csv(p2).loc[0, "id"])
typed_id = pd.read_csv(p2, dtype={"id": "str"}).loc[0, "id"]
p2.unlink()
print("default_id", default_id)
print("typed_id", typed_id)

# Precision.
big_id = 2**53 + 1
p3 = tmpdir / "big.csv"
p3.write_text(f"order_id\n{big_id}\n")
df3 = pd.read_csv(p3)
exact = int(df3.loc[0, "order_id"])
corrupted = int(df3.astype({"order_id": "float64"}).loc[0, "order_id"])
p3.unlink()
print("precision_exact", exact == big_id)
print("precision_corrupted_by_one", big_id - corrupted)

# CSV vs Parquet.
df4 = pd.DataFrame({"order_id": pd.array([1001, 1002, pd.NA], dtype="Int64")})
csv_path = tmpdir / "o.csv"
pq_path = tmpdir / "o.parquet"
df4.to_csv(csv_path, index=False)
df4.to_parquet(pq_path)
csv_dtype = str(pd.read_csv(csv_path)["order_id"].dtype)
parquet_dtype = str(pd.read_parquet(pq_path)["order_id"].dtype)
csv_path.unlink()
pq_path.unlink()
print("csv_dtype", csv_dtype)
print("parquet_dtype", parquet_dtype)

# Chunking.
p5 = tmpdir / "chunks.csv"
p5.write_text("value\n" + "\n".join(str(i) for i in range(1, 1001)) + "\n")
whole = int(pd.read_csv(p5)["value"].sum())
chunked = sum(int(c["value"].sum()) for c in pd.read_csv(p5, chunksize=97))
p5.unlink()
print("chunking_matches", whole == chunked)

tmpdir.rmdir()
PY
)"
echo "${facts}" | sed 's/^/  /'

get_fact() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }

check_eq "the default read turns Namibia's NA into a real missing value" "True" "$(get_fact default_is_missing)"
check_eq "keep_default_na=False keeps the literal string 'NA'" "NA" "$(get_fact kept_value)"
check_eq "the default read silently drops the leading zeros: '00123' becomes 123" "123" "$(get_fact default_id)"
check_eq "dtype={'id': 'str'} preserves the leading zeros exactly" "00123" "$(get_fact typed_id)"
check_eq "int64 preserves an ID past 2**53 exactly" "True" "$(get_fact precision_exact)"
check_eq "a float64 round-trip corrupts that ID by exactly 1" "1" "$(get_fact precision_corrupted_by_one)"
check_eq "a nullable Int64 column survives a CSV round-trip as float64, not Int64" "float64" "$(get_fact csv_dtype)"
check_eq "the same column survives a Parquet round-trip as Int64, unchanged" "Int64" "$(get_fact parquet_dtype)"
check_eq "an aggregate computed chunk-by-chunk (chunksize=97) equals the whole-file aggregate" "True" "$(get_fact chunking_matches)"

# --------------------------------------------------------------------------
echo
echo "5. Prove the harness can fail, then restore it"
# --------------------------------------------------------------------------

broken_script="${lab_dir}/examples/03_precision_loss.py"
cp "${broken_script}" "${broken_script}.bak"
sed -i.tmp 's/read_value == BIG_ID/read_value != BIG_ID/' "${broken_script}"
rm -f "${broken_script}.tmp"
broken_out="$(cd "${lab_dir}/examples" && "${python_bin}" 03_precision_loss.py 2>&1)"
broken_status=$?
mv "${broken_script}.bak" "${broken_script}"
if [ "${broken_status}" -ne 0 ]; then
  check "a deliberately wrong assertion makes 03_precision_loss.py exit non-zero" "yes"
else
  check "a deliberately wrong assertion makes 03_precision_loss.py exit non-zero" "no"
fi
case "${broken_out}" in
  *FAIL:*) check "the broken run reports a FAIL line" "yes" ;;
  *) check "the broken run reports a FAIL line" "no" ;;
esac
restored_out="$(cd "${lab_dir}/examples" && "${python_bin}" 03_precision_loss.py 2>&1)"
restored_status=$?
check_eq "the script is restored and exits 0 again" "0" "${restored_status}"
case "${restored_out}" in
  *"03_precision_loss.py: every assertion held."*)
    check "the restored script reports every assertion held" "yes" ;;
  *) check "the restored script reports every assertion held" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "6. Nothing left behind, and no network dependency baked into the lab"
# --------------------------------------------------------------------------

if grep -rInE 'https?://' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1; then
  check "no URL appears in examples/ or starter/" "no"
else
  check "no URL appears in examples/ or starter/" "yes"
fi

find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

stray="$(find "${lab_dir}" -name '.venv' -prune -o -type d \( -name '__pycache__' -o -name '.pytest_cache' \) -print 2>/dev/null)"
if [ -z "${stray}" ]; then
  check "no __pycache__ or .pytest_cache directories were left behind" "yes"
else
  check "no __pycache__ or .pytest_cache directories were left behind" "no"
  echo "${stray}" | sed 's/^/      /'
fi

stray_data="$(find "${lab_dir}" -name '.venv' -prune -o -type f \( -name '*.csv' -o -name '*.parquet' -o -name '*.db' \) -print 2>/dev/null)"
if [ -z "${stray_data}" ]; then
  check "no .csv, .parquet or .db file was left behind anywhere in the lab" "yes"
else
  check "no .csv, .parquet or .db file was left behind anywhere in the lab" "no"
  echo "${stray_data}" | sed 's/^/      /'
fi

echo
echo "${checks} checks, ${failures} failure(s)."
if [ "${failures}" -ne 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting

Grouped by the message you actually see.

ModuleNotFoundError: No module named 'pandas'

The lab's dependencies live in its own .venv, not on your system Python.

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Or point the test suite at a Python that already has pandas 3.0.5 and pyarrow 25.0.1 installed: PYTHON=/path/to/python3 bash tests/run_tests.sh.

Exercise 1's code column doesn't come back as missing for Namibia

You are probably running an older pandas whose na_values default list differs, or you passed keep_default_na=False by accident. Check the column's value directly with pd.isna(df.loc[0, "code"]) — it should be True on the default read. This lab's captured output on 3.0.5 shows NA becoming NaN with no other argument passed.

Exercise 2's id column already looks right without dtype=

If your source CSV genuinely has no leading zeros to lose (e.g. you typed 123 instead of 00123), the exercise has nothing to demonstrate — check the exact file contents. The dtype={"id": "str"} argument matters specifically when the column, if read numerically, would drop meaningful leading characters.

Exercise 3's precision numbers look "off by more than one"

Confirm you are computing 2**53 + 1 in Python (not in a shell arithmetic context that silently overflows a fixed-width integer) and that the CSV you wrote contains the digits of that exact number with no stray newline or whitespace. int(pd.read_csv(path)["order_id"].iloc[0]) should equal 9007199254740993 before any .astype("float64") cast.

Exercise 4 — the "chronological" order looks the same as the "string" order

This happens if every date in your test file happens to already be zero-padded to the same width — the string sort and the datetime sort only disagree when a format inconsistency exists. This lab's own example deliberately writes one date as 2024-1-9 (no leading zero) specifically to force the disagreement; check your file for the same kind of inconsistency if you are not seeing one.

Exercise 5 doesn't raise UnicodeDecodeError

Some byte sequences that are valid latin-1 also happen to be valid (but different) UTF-8 — in that case you get silent mojibake instead of an exception, which is the OTHER half of the danger this exercise is about. If you genuinely see clean, correct text with encoding="utf-8" on a file you wrote as latin-1, the specific bytes you chose happened not to trigger either failure mode; try including an accented character outside the first 128 code points, as this lab's reference script does.

pip install fails or hangs

You are offline, or a corporate proxy is blocking PyPI. This is the only network-dependent step in the entire lab — everything after installation runs offline, writing only into tempfile.mkdtemp() directories that each script removes itself before exiting. Retry on a connection that can reach pypi.org, or ask whoever manages your network for a mirror.

bash tests/run_tests.sh reports a version mismatch in section 1

The suite checks that the pandas installed in whatever Python it resolves matches requirements/requirements.txt exactly (not just "at least"), because this lab's captured output is tied to the exact pandas 3.0.5 behaviour described in expected-output/FIELDS.md. If you intentionally want to see how an older pandas behaves differently, that is a legitimate thing to explore — just do not expect this lab's checks to pass while you do it.

to_parquet() needs pyarrow, pinned in requirements/requirements.txt alongside pandas. Reinstall with .venv/bin/pip install -r requirements/requirements.txt and confirm with .venv/bin/python3 -c "import pyarrow; print(pyarrow.__version__)".

A .csv, .parquet or .db file is left in the lab directory after a run

Every script in this lab writes into a tempfile.mkdtemp() directory and deletes it in a finally: block before exiting, including on a failed assertion. If a stray file appears, it most likely means a script was interrupted mid-run (Ctrl-C, a killed process) before its cleanup ran. Re-run the harness — tests/run_tests.sh section 6 checks specifically for this and will fail loudly rather than pass silently over it.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, to download pandas, pyarrow and NumPy from PyPI into this lab's own .venv. Every script and test after that runs completely offline. tests/run_tests.sh section 6 greps every file in examples/ and starter/ for a URL and fails the suite if it finds one, so this is checked rather than merely claimed.
  • Writes only inside its own .venv directory (created by you, via python3 -m venv .venv), and inside a fresh tempfile.mkdtemp() directory that each individual exercise script creates for its own small CSV, JSON, SQLite database or Parquet file — and deletes, in a finally: block, before the script exits, whether the exercise's assertions passed or failed.
  • Never opens a network socket, binds a port, needs sudo, or reads or writes any file outside .venv/, its own temporary directories, and transient __pycache__ / .pytest_cache directories the test harness removes both before and after every run.
  • Needs no credential, API key, or account of any kind.

What the data in this lab is

Every value in every exercise is a small literal invented for the demonstration — three or four rows of country codes, customer IDs, order totals, or a single accented name — or a synthetic column of a few thousand to fifty thousand seeded random numbers (np.random.default_rng(42)) generated purely to make the chunking and category-memory exercises meaningful at a realistic scale. Nothing here is real personal, financial, or otherwise sensitive data, and nothing is downloaded from any external dataset. The one "personal-looking" value — the name "José" and the city "São Paulo" in exercise 5 — is a stock example chosen only because it contains a byte sequence that fails predictably under a UTF-8/latin-1 mismatch; it is not associated with a real person.

The design point this day is actually about

read_csv() is a type-inference engine, and every exercise in this lab is really about the moment that inference goes unnoticed. An identifier column silently read as an integer, or a country code silently read as a missing value, is not a security vulnerability in the traditional sense — but the same silent-substitution mechanism is exactly how a data pipeline can quietly merge or export the wrong record with no error anywhere in the logs. Exercise 3's precision loss is the sharpest version of this: an identifier that changes value with no warning is a correctness failure that, in a system where identifiers gate access or authorization, would be a security failure too. This lab does not exploit anything; it makes the mechanism visible before it reaches a system where the stakes are real.

The encoding failure in exercise 5 has a related, quieter implication: UnicodeDecodeError is the GOOD outcome here, because it fails loudly. A byte sequence that happens to decode as different-but-valid text under the wrong encoding — mojibake — fails silently, and a system that logs or displays that mojibake without noticing has a data-integrity problem that looks, to an inattentive reviewer, like everything worked.