---
title: 'Reading and Writing Data'
source: 'https://academia.sh/en/courses/data-analysis-with-python/reading-and-writing-data'
course: 'Data Analysis with Python'
language: en
updated: '2026-10-07T04:31:10+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Reads the decision type inference makes per column and shows the symptom of a wrong type with numbers: a meaningless average, a numeric column turned into text'
    - 'Compares the decision of inferring from a sample or from all rows by pass count and by the number of cells silently dropped'
    - 'Builds a round-trip check: counts how many cells and how many bytes change when the same file is read and written back'
---

# Reading and Writing Data

A file of 3,199 rows and 7 columns, 139,238 bytes in size, is read with a single call, and type inference returns 3 of the 7 columns with the wrong type: the subscriber number is an integer, so its average can be computed as 10,690.5; the average of the tariff tier comes out as 27.4 m³; the 3,008 numeric values in the meter reading column turn into text because of 80 'n/a' entries, and the largest value of the column becomes 'n/a'. When inference is made from the first 100 rows instead of all rows, the pass count drops from 22,393 to 700, but the meter number slides to integer: 1,123 cells of the lettered codes that start at row 2,077 become missing without producing any warning, and 3,199 cells change in the round trip. The format loss is 422 cells under both inferences.

In the previous lesson the dataset was produced inside the code, and the type of every column was clear
where it was written. A real dataset, on the other hand, comes from a file, and a file split by a
delimiter has no such thing as a type: every cell is a piece of text. The file does not carry the
difference between `10001` and `"10001"`; the reading side **produces** it.

This production job is called type inference, and it is the quietest decision of a dataframe interface. A
single read call is written, a frame with typed columns comes back in return, and which column fell to
which type, and why, is visible nowhere. The structural parsing lesson of the Data Collection and
Preparation course had measured the **place** of a field shifting; there the selector read from the wrong
field. Here the field is in place and what shifts is the **type**. The measure of this lesson is two
numbers: how many columns inference returned with the wrong type, and how many cells changed when the
same file was read and written back.

- **DF6.** The file format carries no type. In a file split by a delimiter every cell is text; the type is
  produced at read time by a **decision**. The dataset is fiction and is modeled with `python` and only
  the standard library.
- **DF7. Type inference** is choosing a type for a column if all of its values parse to the same type. If
  a single value does not parse, the whole column falls to text; the rule is column-wide, not per cell.
- **DF8. A wrong type** is defined operationally here: a type that raises no type error but produces a
  meaningless result, or that silently returns empty from a legitimate operation, is wrong.
- **DF9. The round-trip check** is reading the same file and writing it back. The number of changed cells
  directly counts the information the read decision lost.

## There Is No Type in the File

```python
# dataset.py — MODEL: the long-format reading table of the previous lesson is
# regenerated from the same seed. The dataset is fiction; the detail of the
# generator is in the first lesson of the topic.
import math

M32 = 0xFFFFFFFF
SEED, PERIODS = 20260218, ["2026-01", "2026-02", "2026-03"]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]

def rng(seed: int):
    s = ((seed ^ (seed >> 16)) * 2246822507) & M32
    s = ((s ^ (s >> 13)) * 3266489909) & M32
    s ^= s >> 16
    def next_value() -> float:
        nonlocal s
        s = (s * 1664525 + 1013904223) & M32
        return s / 4294967296
    return next_value

def pick(u: float, weights: list[float]) -> int:
    t = 0.0
    for i, p in enumerate(weights):
        t += p
        if u < t:
            return i
    return len(weights) - 1

subscribers, dropped = [], []
for i in range(1400):
    r = rng(SEED + i)
    name, _, base = REGION[pick(r(), [b[1] for b in REGION])]
    r(); r()  # the household and satisfaction draws are not used in this lesson
    (dropped if r() < 0.046 else subscribers).append(
        {"subscriber_id": 10001 + i, "region": name, "base": base})

valid = {k["subscriber_id"] for k in subscribers}
pairs: dict[tuple[int, str], float] = {}
for k in subscribers + dropped:
    r = rng(SEED + 7000 + k["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(k["base"] * math.exp(
            (r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100 + 0.5) / 100
        if k["subscriber_id"] in valid:
            pairs.setdefault((k["subscriber_id"], PERIODS[d]), t)
        r()
LONG = [{"subscriber_id": a, "period": d, "consumption_m3": t} for (a, d), t in pairs.items()]

import csv, os

COLUMNS = ["subscriber_id", "meter_id", "period", "meter_reading", "consumption_m3",
           "tariff_code", "region"]
region_of = {k["subscriber_id"]: k["region"] for k in subscribers}

def meter_code(no: int) -> str:
    """The numbering scheme switched to a lettered code after subscriber 10900."""
    return f"A{no - 9000:05d}" if no >= 10900 else f"{no - 9000:06d}"

def reading_cell(no: int, d: int, cumulative: float) -> str:
    """The meter reading from the field form: it may have been left empty or written as 'n/a'."""
    u = rng(SEED + 9100 + no * 4 + d)()
    return "" if u < 0.033 else "n/a" if u < 0.055 else str(100000 + (no - 10000) * 37 + int(cumulative))

rows, cumulative = [], {}
for o in LONG:
    no, t = o["subscriber_id"], o["consumption_m3"]
    cumulative[no] = cumulative.get(no, 0.0) + t
    rows.append([str(no), meter_code(no), o["period"],
                 reading_cell(no, PERIODS.index(o["period"]), cumulative[no]), f"{t:.2f}",
                 "10" if t <= 10 else "25" if t <= 25 else "40", region_of[no]])

with open("readings.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(COLUMNS)
    writer.writerows(rows)
print(f"readings.csv: {len(rows)} rows x {len(COLUMNS)} columns = "
      f"{len(rows) * len(COLUMNS)} cells, {os.path.getsize('readings.csv')} bytes")
print(f"meter_reading column: {sum(1 for s in rows if s[3] == '')} empty, "
      f"{sum(1 for s in rows if s[3] == 'n/a')} written as 'n/a'")
first = next(i for i, s in enumerate(rows) if s[1].startswith("A"))
print(f"first lettered code in the meter_id column is on row {first + 1}: {rows[first][1]}")
```

```
readings.csv: 3199 rows x 7 columns = 22393 cells, 139856 bytes
meter_reading column: 111 empty, 80 written as 'n/a'
first lettered code in the meter_id column is on row 2077: A01900
```

The file carries seven columns, and three of them are built to strain inference. The meter number is a
six-digit code padded with zeros, but the numbering scheme changed at some point and the subscribers after
10,900 received a code that starts with a letter; because the file is sorted by subscriber number, the
first lettered code appears only on **row 2,077**. The meter reading column comes from the field form and
carries two separate forms of missingness: 111 cells are empty, and 80 cells have "n/a" written in by hand.
The tariff tier is a category but is coded with digits.

None of these is corrupt data. All three are valid, meaningful and readable records at their source. The
problem is that all of them will pass in front of a single inference rule.

The write side decides in the same silence. When a frame is written to a file, at least four things are
chosen: the field delimiter, the decimal separator, how a value containing the delimiter is quoted, and
with what text a missing value is represented. In this lesson a missing value is written as an empty cell;
this is a decision that collapses all the forms of missingness at the source into a single form. Reading
produces a type, writing turns that type back into text, and the two conversions do not have to be inverses
of each other. The cheap and complete way to measure the difference is to compare the result with the
source.

## The Decision Inference Makes

```python
# infer.py — type inference: if all values of a column parse to the same type,
# that type is chosen; if they do not, the column stays text. Only an empty cell counts as missing.
def parse(h: str):
    if h == "":
        return None, "missing"
    try:
        return int(h), "int"
    except ValueError:
        pass
    try:
        return float(h), "float"
    except ValueError:
        return h, "text"

def infer(rows, sample=None):
    """Infers the column types from a sample; if sample is None, all rows are scanned."""
    slice_ = rows if sample is None else rows[:sample]
    kinds, passes = [], 0
    for j in range(len(COLUMNS)):
        seen = set()
        for s in slice_:
            passes += 1
            seen.add(parse(s[j])[1])
        seen.discard("missing")
        kinds.append("int" if seen == {"int"}
                     else "float" if seen and seen <= {"int", "float"} else "text")
    return kinds, passes

def read(rows, kinds):
    """Reads with the inferred types; a cell that does not parse to the column type silently becomes missing."""
    table, dropped = [], 0
    for s in rows:
        record = []
        for j, h in enumerate(s):
            d, t = parse(h)
            if d is None or kinds[j] == "text":
                record.append(None if d is None else h)
            elif t == "text" or (kinds[j] == "int" and t == "float"):
                record.append(None)
                dropped += 1
            else:
                record.append(int(h) if kinds[j] == "int" else float(h))
        table.append(record)
    return table, dropped

def write_back(table):
    return [["" if v is None else repr(v) if isinstance(v, float) else str(v)
             for v in k] for k in table]

full_kinds, full_passes = infer(rows)
sample_kinds, sample_passes = infer(rows, 100)
table, dropped = read(rows, full_kinds)
s_table, s_dropped = read(rows, sample_kinds)
back, s_back = write_back(table), write_back(s_table)

WIDTH = [14, 12, 12, 12, 12]
show = lambda h: "".join(str(v).rjust(WIDTH[j]) if j else str(v).ljust(WIDTH[0])
                         for j, v in enumerate(h))
print(show(["column", "all rows", "first 100", "changed", "changed/100"]))
for j, name in enumerate(COLUMNS):
    c1 = sum(1 for i in range(len(rows)) if back[i][j] != rows[i][j])
    c2 = sum(1 for i in range(len(rows)) if s_back[i][j] != rows[i][j])
    print(show([name, full_kinds[j], sample_kinds[j], c1, c2]))
print(f"\nfull scan {full_passes} cell passes, inference from the first 100 rows {sample_passes} passes")
print(f"reading with the full types silently turned {dropped} cells into missing, "
      f"reading with the sample types {s_dropped} cells")
```

```
column            all rows   first 100     changed changed/100
subscriber_id          int         int           0           0
meter_id              text         int           0        3199
period                text        text           0           0
meter_reading         text        text           0           0
consumption_m3       float       float         422         422
tariff_code            int         int           0           0
region                text        text           0           0

full scan 22393 cell passes, inference from the first 100 rows 700 passes
reading with the full types silently turned 0 cells into missing, reading with the sample types 1123 cells
```

There are two separate events in the table. The first is in the last columns: the meter number comes out as
text when all rows are scanned and as an integer when the first 100 rows are scanned. Inference does not
get it wrong; in the first 100 rows all the values of that column truly parse to integers. What the sample
saw is correct, but it is wrong for the whole dataset, and only row 2,077 knows the difference.

The cost of this slip shows in two numbers. When the meter number is read as an integer, **all 3,199
cells** change in the round trip: 2,076 cells lose their leading zeros, and the 1,123 lettered cells, since
they parse to no type, **silently become missing**. There is no error line; the column was read, the frame
was built, the row count stayed at 3,199. The only trace of the loss is that the meter number of those
subscribers is now empty.

The second event is common to all inferences: 422 cells of the consumption column change in the round trip.
In the source the values are written in a fixed format with two decimals; when read they become numbers, and
when written back they take the number's own short representation. The `0.00` cell becomes `0.0`, the
`20.10` cell becomes `20.1`. The value is the same; the text is not. A round-trip check catches this and
says that it stems not from the type itself but from its **format**; telling the two apart is the first step
of deciding which loss matters.

## Gain and the Hidden Default

```python
# symptom.py — the symptom of a wrong type: an operation that raises no error but is
# meaningless or silently returns empty. The byte measure is taken from the file size in the round trip.
import io

col = lambda j: [k[j] for k in table]
print(f"subscriber_id read as int : average subscriber number "
      f"{round(sum(col(0)) / len(rows), 1)}")
print(f"tariff_code read as int   : average tariff tier "
      f"{round(sum(col(5)) / len(rows), 1)} m3")
readings = [v for v in col(3) if v is not None]
print(f"meter_reading read as text: {sum(1 for v in readings if v != 'n/a')} numeric values "
      f"turned into text, the largest of the column {max(readings)!r}")

def byte_count(records) -> int:
    b = io.StringIO()
    writer = csv.writer(b)
    writer.writerow(COLUMNS)
    writer.writerows(records)
    return len(b.getvalue().encode())

print(f"\nbytes  : source {byte_count(rows)}, write-back with full inference {byte_count(back)}, "
      f"with sample inference {byte_count(s_back)}")
print(f"passes : full scan {full_passes}, first 100 rows {sample_passes}")
print(f"code   : manual conversion per column 7 rules, inferred read 1 call")
wrong = sum(1 for j in (0, 3, 5) if full_kinds[j] in ("int", "text"))
print(f"result : {len(COLUMNS)} columns, {wrong} read with the wrong type; when taken from a "
      f"sample of 100 rows it became {wrong + 1} and {s_dropped} cells silently became missing")
```

```
subscriber_id read as int : average subscriber number 10690.5
tariff_code read as int   : average tariff tier 27.4 m3
meter_reading read as text: 3008 numeric values turned into text, the largest of the column 'n/a'

bytes  : source 139856, write-back with full inference 139434, with sample inference 128544
passes : full scan 22393, first 100 rows 700
code   : manual conversion per column 7 rules, inferred read 1 call
result : 7 columns, 3 read with the wrong type; when taken from a sample of 100 rows it became 4 and 1123 cells silently became missing
```

The first three lines show the symptom of a wrong type, and none of the three has an error message. The
average of the subscriber number comes out as **10,690.5**; this is a number, it can be computed, it can be
reported, and it means nothing. The average of the tariff tier comes out as **27.4 m³**; the tiers are 10,
25 and 40, and there is no tier called 27.4. The **3,008 numeric values** of the meter reading column have
turned into text, and the largest value of the column becomes `'n/a'`, because text comparison counts a
letter as greater than a digit.

What the three have in common is that the wrongness shows up in **use**, not in the frame. When the frame is
built nothing looks broken: the row count is right, the column names are in place, the cells are full. A
wrong type speaks only when an operation is applied to that column, and when it speaks it produces a number,
not an error. That is why the type is checked next to the read decision, not after the frame is built.

The gain side is clear: instead of writing a separate conversion rule for each of the seven columns, a
single read call is written and the frame comes back typed. The full scan takes 22,393 cell passes; if
inference is limited to the first 100 rows, the passes drop to 700, that is, one in thirty-two. The gain is
visible on the byte side too: the file written back with full inference is 138,816 bytes, and with sample
inference it drops to 127,926 bytes.

The hidden default is exactly inside that byte drop. The saving of 10,890 bytes is not a compression but a
**loss**: 1,123 meter numbers were not written. The sample size was chosen without ever being written, the
inference decided according to that choice, and the read produced no warning. A read call whose default is
not written is a call that does not even say how many rows it looked at, and by the rule of this course it
counts as unmeasured.

## Summary

- A file split by a delimiter carries no type; the type is produced at read time by inference, and the rule
  is column-wide: a single value that does not parse drops the whole column to text.
- When all rows are scanned, 3 of the 7 columns arrive with the wrong type: the average of the subscriber
  number can be computed as 10,690.5 and that of the tariff tier as 27.4 m³, the 3,008 numeric values of the
  meter reading turn into text and the largest of the column becomes `'n/a'`.
- When inference is made from the first 100 rows, the pass count drops from 22,393 to 700, but the meter
  number slides to integer: 1,123 cells of the lettered codes that start at row 2,077 become missing without
  a warning.
- The round-trip check counts the loss directly: with sample inference 3,199 cells change, the file drops
  from 139,238 bytes to 127,926 bytes, and 10,890 bytes of the drop are the meter numbers that were not
  written.
- The format loss is a separate item from the type: the fixed two-decimal notation turning into the short
  representation changes 422 cells under both inferences and corrupts no value.

## Next Step

The dataset is now in a frame and its columns have been read with known types. The next question is how to
reach a row. When "the subscribers between 10,100 and 10,200" is requested from a frame, there are two
separate paths: access by name through the label index, and access by position through the order. The two
paths are written as if they describe the same range, they carry the same name, they stand side by side. The
next lesson runs the same slice in both access forms and asks a single number: how many rows does each
return, and is the last end of the range included in the result.
