Migrating from Legacy Systems

Where to Store Production Test Results

Test results scattered across spreadsheets and shared folders stop working at a predictable point. Here is when to move, and what to move to.

JJulien Buteau
beginner8 min readMarch 14, 2026

Most hardware teams log production test results in a spreadsheet. It works, until one day it doesn't, and the failure is usually described the same way: results are all over the place, in random folders on people's laptops, the shared drive and the cloud, and the sheet has become too cumbersome to maintain.

This guide covers when a spreadsheet stops being the right answer, what to move to, and how to migrate without losing history.

When a spreadsheet is genuinely fine

Worth saying first, because the answer is not always "buy something".

A spreadsheet works when one person logs results from one station, volumes are low, and nobody needs to answer questions across months of data. Prototype builds and pilot runs fit this comfortably. If that is you, the effort of moving is not yet repaid.

The signals that it has stopped working are specific:

  • Two people need to log at once. Spreadsheets are single-writer in practice. Merge conflicts and overwrites start immediately.
  • Someone asks for yield over a period. Answering means a pivot table that breaks the next time a column moves.
  • A returned unit needs its history. Finding one serial number across a year of files is a manual search.
  • The person who built the sheet is the only one who can maintain it. This is the bus-factor problem, and it is usually what actually forces the change.

The spreadsheet pattern

A typical production test log looks like this:

Serial NumberDateOperatorResultVoltage (V)Current (A)FirmwareNotes
SN-0012025-01-15AlicePASS3.310.52v2.1
SN-0022025-01-15BobFAIL3.580.89v2.1Over current limit
SN-0032025-01-16AlicePASS3.290.48v2.1

The structural problems:

  • No concurrent access. Two operators cannot log at the same time without risking overwrites.
  • No validation. Nothing stops "PSAS" instead of "PASS", or voltage entered in the current column.
  • No analytics. Yield, capability and failure trends mean fragile formulas that break when the sheet structure changes.
  • No history. Edit a cell and the original value is gone. There is no audit trail.
  • Limits live in your head. The sheet records 3.31 but not that the limit was 3.1 to 3.5. When limits change, old rows become uninterpretable.

That last one is the most damaging and the least noticed.

Option 1: your own database

A Postgres schema with unit, run, step and measurement tables, plus Metabase or Grafana on top, is a perfectly respectable answer. It handles concurrency, gives you SQL for any question, and costs nothing in licensing.

The catch is not the build, it is the maintenance. Yield is easy. What takes the time is capability analysis done correctly per measurement, control limits that survive a spec change, first pass yield that counts the first run per unit rather than any passing run, and traceability through sub-assemblies. Each is a small project. Together they are the thing nobody has time to keep up.

Choose this when you have someone to own it and requirements no product matches.

Option 2: a test data platform

The alternative is a system built for this, where the schema and the analytics come with it.

Here is the same test as the spreadsheet above, written as an OpenHTF test that logs automatically:

power_board_test.py
28 lines
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("voltage").with_units(units.VOLT).in_range(3.1, 3.5),    htf.Measurement("current").with_units(units.AMPERE).in_range(maximum=0.7),)def power_supply_check(test):    voltage = 3.31  # Read from your instrument    current = 0.52    test.measurements.voltage = voltage    test.measurements.current = currentdef main():    test = htf.Test(        power_supply_check,        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",  # procedure UUID from the dashboard        part_number="PCB-V1",    )    test.add_output_callbacks(upload())    test.execute(lambda: "SN-001")if __name__ == "__main__":    main()

Install it with pip install "tofupilot[openhtf]". The upload() callback reads TOFUPILOT_API_KEY from the environment.

Each run captures the serial number, the outcome, every measurement with its limits and units, timestamps and the station identity. The measurement definition in the test code is the schema, so there is nothing separate to design or migrate.

What changes

CapabilitySpreadsheetOwn databaseTest data platform
Data entryManualFrom test codeFrom test code
Concurrent accessSingle writerYesYes
Limits stored with resultsNoIf you model itYes
Yield trendsManual formulasSQL you writeBuilt in
Capability and control chartsCustom macrosYou build themBuilt in
Failure ParetoManual filteringSQL you writeBuilt in
Sub-assembly traceabilityNoIf you model itBuilt in
Audit trailNoneIf you model itFull history per run
Ongoing costYour timeEngineering timeLicence

Keeping your existing data

Historical spreadsheet data imports through the API. Structure each row as a run with a phase and its measurements:

import_from_csv.py
49 lines
import csvimport osfrom datetime import datetime, timezonefrom tofupilot.v2 import TofuPilotPROCEDURE_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"with TofuPilot(api_key=os.getenv("TOFUPILOT_API_KEY")) as client:    with open("test_results.csv") as f:        for row in csv.DictReader(f):            outcome = "PASS" if row["Result"] == "PASS" else "FAIL"            tested_at = datetime.strptime(row["Date"], "%Y-%m-%d").replace(                tzinfo=timezone.utc            )            client.runs.create(                procedure_id=PROCEDURE_ID,                serial_number=row["Serial Number"],                part_number="PCB-V1",                outcome=outcome,                started_at=tested_at,                ended_at=tested_at,                phases=[{                    "name": "Power Supply Check",                    "outcome": outcome,                    "started_at": tested_at,                    "ended_at": tested_at,                    "measurements": [                        {                            "name": "Output Voltage",                            "measured_value": float(row["Voltage (V)"]),                            "units": "V",                            "validators": [                                {"operator": ">=", "expected_value": 3.1},                                {"operator": "<=", "expected_value": 3.5},                            ],                        },                        {                            "name": "Supply Current",                            "measured_value": float(row["Current (A)"]),                            "units": "A",                            "validators": [                                {"operator": "<=", "expected_value": 0.7},                            ],                        },                    ],                }],            )

Note that the limits become validators on the measurement, which is what makes the historical rows interpretable later. Run the script once to backfill, then point new tests at the platform. Importers also exist for TestStand, STDF and other formats if your history is not in CSV.

Test the script against a handful of rows before running the whole file, and keep the original CSV until you have confirmed the imported yield matches.

Migrating without risk

You do not have to switch everything at once:

  1. Pick one test station and add the integration to its tests.
  2. Run both systems for a week. Keep the spreadsheet going while the platform collects the same data automatically.
  3. Compare the yield numbers on the same units. When they disagree, the reason usually tells you something about your own data model, most often that the spreadsheet was counting any passing run rather than the first run per unit.
  4. Roll out to the remaining stations once the numbers agree.

The spreadsheet stays as a backup until you are ready to retire it.

What to insist on, whichever you choose

Store the measured value, not just pass or fail. A boolean tells you a unit failed. The value tells you it was drifting for three weeks.

Store the limits alongside the value. Otherwise old results become uninterpretable the first time limits change.

Version the test procedure. Same reason, and it is nearly impossible to retrofit.

Model sub-assemblies from day one. Reconstructing which board went into which assembly after the fact means reading build records by hand.

Those four decisions are far cheaper to make at the start than to add later, regardless of where the data ends up living.

More Guides

Put this guide into practice