The six interop hazards
Break it on purposeNeeds Python with NumPy
Problem
Data that crosses a boundary between systems is reinterpreted by the reader, and most reinterpretations fail silently: the reader gets a value, just not the one written. Six hazards recur: text encoding, exact decimals read as binary floats, the difference between null, empty and missing, times without a zone, integers wider than the reader's type, and sort order (collation).
Create one small file in Python and read it back with a different assumption for each hazard: write UTF-8 and read as Latin-1; write cents as exact decimals and read as float and sum 1,000,000 values; write null, "" and a missing field and count each in a reader; write a time without a zone and read it under two zones; write the integer 2,147,483,648 and read it into a 32-bit column (numpy.int32); sort a few names by raw bytes and by a case-insensitive collation.
The question. How many of the six hazards change the data, and how many raise an error?
Predict
Each reader finishes without any error, and the numbers or orders differ.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
the-six-interop-hazards.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The six interop hazards. One small dataset read back with a different assumption per hazard: UTF-8 as Latin-1, decimal cents as float, null against "" against missing, a time with no zone under two zones, a big integer into a 32-bit column, and names sorted by bytes against a case-insensitive collation. No reader raises an error. """ import argparse, json from datetime import datetime from decimal import Decimal from zoneinfo import ZoneInfo import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--values", type=int, default=1_000_000, help="Cent amounts summed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rows = [] def report(hazard, written, read, raised=False): rows.append({"hazard": hazard, "written": str(written), "read": str(read), "differs": "yes" if str(written) != str(read) else "no", "raised_error": "yes" if raised else "no"}) print(f"{hazard:<28} wrote {str(written)[:34]:<36} read {str(read)[:34]:<36}") name = "Zoë Müller"; report("encoding", name, name.encode("utf-8").decode("latin-1")) cents = [Decimal("0.10")] * args.values total = 0.0 for c in cents: total += float(c) # a plain running total, as most aggregation code keeps report("decimal read as float", f"{sum(cents):.10f}", f"{total:.10f}") # (Python 3.12+'s sum() compensates and would hide it) records = [{"note": None}, {"note": ""}, {}] report("null, empty, missing", "1 null, 1 empty, 1 missing", f"{sum(1 for r in records if not r.get('note'))} 'no note' (all three the same)") naive = datetime(2025, 3, 30, 1, 30) a = naive.replace(tzinfo=ZoneInfo("Europe/London")).astimezone(ZoneInfo("UTC")); b = naive.replace(tzinfo=ZoneInfo("Asia/Kolkata")).astimezone(ZoneInfo("UTC")) report("time without a zone", naive.isoformat(), f"{a:%H:%M} UTC or {b:%H:%M} UTC") report("integer into int32", 2_147_483_648, int(np.array([2_147_483_648]).astype(np.int32)[0])) names = ["apple", "Banana", "cherry", "Apple"] report("collation", ", ".join(sorted(names, key=str.lower)), ", ".join(sorted(names, key=lambda s: s.encode()))) print(f"{sum(r['differs'] == 'yes' for r in rows)} of {len(rows)} hazards changed the data; exceptions raised: {sum(r['raised_error'] == 'yes' for r in rows)}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"hazards": rows, "summary": [{"differences": sum(r['differs'] == 'yes' for r in rows), "errors": 0}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run the-six-interop-hazards.py uv run the-six-interop-hazards.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --values1,000,000 | Cent amounts summed. |
Our run
Written one way, read another
| Hazard | Written | Read |
|---|---|---|
| encoding | Zoë Müller | Zoë Müller |
| decimal read as float | 100000.0000000000 | 100000.0000013329 |
| null, empty, missing | 1 null, 1 empty, 1 missing | 3 'no note' (all three the same) |
| time without a zone | 2025-03-30T01:30:00 | 01:30 UTC or 20:00 UTC |
| integer into int32 | 2147483648 | -2147483648 |
| collation | apple, Apple, Banana, cherry | Apple, Banana, apple, cherry |
encoding wrote Zoë Müller read Zoë Müller
decimal read as float wrote 100000.0000000000 read 100000.0000013329
null, empty, missing wrote 1 null, 1 empty, 1 missing read 3 'no note' (all three the same)
time without a zone wrote 2025-03-30T01:30:00 read 01:30 UTC or 20:00 UTC
integer into int32 wrote 2147483648 read -2147483648
collation wrote apple, Apple, Banana, cherry read Apple, Banana, apple, cherry
6 of 6 hazards changed the data; exceptions raised: 0What we found
6 of 6 hazards changed the data and 0 raised an exception. The name became Zoë Müller; the exact total became 100000.0000013329; null, empty and missing were read as 3 'no note' (all three the same); the zoneless time was read as 01:30 UTC or 20:00 UTC; 2,147,483,648 became -2147483648; and the byte-order sort gave Apple, Banana, apple, cherry.
Read it
None of these is a bug in a reader; each is a decision the writer did not record. An interface that does not state encoding, number type, null semantics, time zone, integer width and collation leaves each reader to guess, and a guess that runs without error is the most expensive kind. Put them in the schema or the contract.