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 Number | Date | Operator | Result | Voltage (V) | Current (A) | Firmware | Notes |
|---|---|---|---|---|---|---|---|
| SN-001 | 2025-01-15 | Alice | PASS | 3.31 | 0.52 | v2.1 | |
| SN-002 | 2025-01-15 | Bob | FAIL | 3.58 | 0.89 | v2.1 | Over current limit |
| SN-003 | 2025-01-16 | Alice | PASS | 3.29 | 0.48 | v2.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.py28 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
| Capability | Spreadsheet | Own database | Test data platform |
|---|---|---|---|
| Data entry | Manual | From test code | From test code |
| Concurrent access | Single writer | Yes | Yes |
| Limits stored with results | No | If you model it | Yes |
| Yield trends | Manual formulas | SQL you write | Built in |
| Capability and control charts | Custom macros | You build them | Built in |
| Failure Pareto | Manual filtering | SQL you write | Built in |
| Sub-assembly traceability | No | If you model it | Built in |
| Audit trail | None | If you model it | Full history per run |
| Ongoing cost | Your time | Engineering time | Licence |
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.py49 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:
- Pick one test station and add the integration to its tests.
- Run both systems for a week. Keep the spreadsheet going while the platform collects the same data automatically.
- 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.
- 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.
