Skip to content
About
Main content

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 , empty and missing, times without a zone, integers wider than the reader's type, and sort order ().

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

Of the six interop hazards, how many raise an error?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    the-six-interop-hazards.py48 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--values1,000,000 Cent amounts summed.

Our run

Written one way, read another

Written one way, read another
HazardWrittenRead
encodingZoë MüllerZoë Müller
decimal read as float100000.0000000000100000.0000013329
null, empty, missing1 null, 1 empty, 1 missing3 'no note' (all three the same)
time without a zone2025-03-30T01:30:0001:30 UTC or 20:00 UTC
integer into int322147483648-2147483648
collationapple, Apple, Banana, cherryApple, Banana, apple, cherry
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-six-interop-hazards.py
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: 0
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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.