Skip to content
Test Data & Analytics

How to Store Manufacturing Test Results

A practical guide to storing production test results: flat files, SQL schemas, STDF and ATML standards, and when each approach stops scaling.

JJulien Buteau
beginner10 min readSeptember 7, 2026

Ask how to store manufacturing test results and the most common answer you will find in forums is that there is no industry standard. That answer is half right. There is no single standard covering board-level functional test the way STDF covers semiconductor test. But there are well-understood approaches, each with a known point at which it stops working.

This guide covers four of them in the order teams usually encounter them, and says plainly where each one breaks.

What You Actually Need to Store

Before choosing a format, be clear about the entities. Almost every team converges on the same five:

EntityWhat it is
UnitA physical thing with a serial number
RunOne execution of a test procedure against one unit
StepOne phase within a run, with its own pass or fail
MeasurementA named value with units and limits
ProcedureThe versioned definition of what was tested

The two that get missed early and hurt later are procedure version and sub-assembly relationships. If you cannot answer "which version of the test produced this result" or "which PCBA went into this final assembly", you will rebuild your schema.

Approach 1: Flat Files

CSV or JSON written to a network share, one file per run.

log_to_csv.py
import csvfrom datetime import datetime, timezonedef log_run(serial, procedure, measurements, passed):    stamp = datetime.now(timezone.utc).isoformat()    path = f"//test-share/results/{serial}_{stamp}.csv"    with open(path, "w", newline="") as f:        writer = csv.writer(f)        writer.writerow(["serial", "procedure", "measurement", "value", "unit", "low", "high", "passed"])        for m in measurements:            writer.writerow([                serial, procedure, m["name"], m["value"],                m["unit"], m["low"], m["high"], m["passed"],            ])

Works until: you need to answer a question across files. First-pass yield for last month means parsing thousands of files. A schema change means old files no longer parse the same way. Two stations writing at once eventually corrupt something. Nobody can find the file for a specific serial without a naming convention that itself becomes a schema.

Realistic ceiling: one station, low volume, or a genuinely temporary setup.

Approach 2: A SQL Schema You Own

The natural next step, and the right answer for many teams.

schema.sql
CREATE TABLE unit (    id            BIGSERIAL PRIMARY KEY,    serial_number TEXT NOT NULL UNIQUE,    part_number   TEXT NOT NULL,    created_at    TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE run (    id                BIGSERIAL PRIMARY KEY,    unit_id           BIGINT NOT NULL REFERENCES unit(id),    procedure_id      TEXT NOT NULL,    procedure_version TEXT NOT NULL,    station_id        TEXT NOT NULL,    started_at        TIMESTAMPTZ NOT NULL,    duration_s        NUMERIC NOT NULL,    passed            BOOLEAN NOT NULL);CREATE TABLE measurement (    id          BIGSERIAL PRIMARY KEY,    run_id      BIGINT NOT NULL REFERENCES run(id) ON DELETE CASCADE,    step_name   TEXT NOT NULL,    name        TEXT NOT NULL,    value       DOUBLE PRECISION,    unit        TEXT,    lower_limit DOUBLE PRECISION,    upper_limit DOUBLE PRECISION,    passed      BOOLEAN NOT NULL);CREATE INDEX ON run (procedure_id, started_at DESC);CREATE INDEX ON measurement (run_id);CREATE INDEX ON measurement (name, run_id);

First-pass yield then becomes a query rather than a script:

first_pass_yield.sql
SELECT    date_trunc('day', first_run.started_at) AS day,    count(*) FILTER (WHERE first_run.passed) * 100.0 / count(*) AS fpy_percent,    count(*) AS units_testedFROM (    SELECT DISTINCT ON (unit_id)        unit_id, started_at, passed    FROM run    WHERE procedure_id = 'FCT-001'    ORDER BY unit_id, started_at ASC) AS first_runGROUP BY dayORDER BY day DESC;

Note the DISTINCT ON and the ascending sort. First-pass yield means the first run per unit, not any passing run. Getting this wrong is the single most common reporting bug in home-built systems, and it always inflates the number.

Works until: the analytics backlog grows faster than you can clear it. Cpk, control charts with proper limits, Pareto of failure modes, drift detection, per-station comparison, and operator-facing reports are each a small project. Then someone asks for the same numbers across three sites with intermittent connectivity.

Realistic ceiling: healthy for a long time if someone owns it. The cost is ongoing engineering, not licensing.

Approach 3: Industry Standards

Two standards matter, and neither covers everything.

STDF (Standard Test Data Format) is the semiconductor standard from Teradyne, now widely supported by ATE. It is binary, compact, and assumes wafers, dies, and bins. If you are testing silicon, use it. If you are testing assembled boards, its data model does not fit and forcing it costs more than it saves.

ATML (Automatic Test Markup Language) is the IEEE 1671 family, XML-based, and designed for test description and results interchange, largely in aerospace and defense. It is thorough and verbose. It is most useful when a contract requires it.

There is also WSJF and WSXF, which are WATS-specific rather than open standards, and worth knowing about mainly because migrating off WATS means dealing with them.

The honest summary: for board-level functional test, no standard has achieved the adoption STDF has in semiconductors. Most teams end up with a proprietary schema, whether their own or a vendor's.

Approach 4: A Test Data Platform

Use a system built for this, so the schema and the analytics come with it.

functional_test.py
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("rail_3v3")    .in_range(3.2, 3.4)    .with_units(units.VOLT),    htf.Measurement("current_draw")    .in_range(0.1, 0.5)    .with_units(units.AMPERE),)def power_rail_test(test):    test.measurements.rail_3v3 = 3.31    test.measurements.current_draw = 0.24def main():    test = htf.Test(        power_rail_test,        procedure_id="FCT-001",        part_number="PCBA-200",    )    test.add_output_callbacks(upload())    test.execute(test_start=lambda: input("Scan serial: "))if __name__ == "__main__":    main()

The measurement definition carries the name, limits, and units, so storage and pass/fail evaluation follow from the test code rather than being maintained separately. Yield, Cpk, control charts, and failure Pareto are computed rather than queried.

Options in this space include WATS, which is the long-standing incumbent, and TofuPilot. Both handle the entities above. They differ mainly in deployment model, licensing shape, and whether test code lives in your Git repository.

Works until: you have a requirement genuinely outside the product's model. At that point you are back to Approach 2, ideally with the vendor's export.

Choosing

SituationApproach
One station, prototype volumeFlat files, but plan the exit
Multiple stations, engineer available to own itSQL schema
Semiconductor testSTDF
Contract mandates a formatATML or whatever the contract says
Board-level test, want analytics without building themTest data platform

Mistakes That Cost the Most

Not versioning the procedure. When limits change, old results become uninterpretable unless you recorded which version produced them.

Storing pass or fail without the value. A boolean tells you a unit failed. The measured value tells you it was drifting for three weeks beforehand.

Ignoring sub-assemblies. Serial number traceability from final assembly down to component boards is retrofitted at great cost. Model it on day one even if you do not use it yet.

Computing yield from any passing run. First-pass yield is the first run per unit. Counting retests inflates it, sometimes dramatically.

No timezone discipline. Store UTC. Three sites in three timezones will otherwise disagree about which day a failure occurred.

More Guides

Put this guide into practice