Test Data & Analytics

What Is First Pass Yield and How to Track It

First pass yield is the share of units passing without rework. How to define it honestly, what corrupts it, and how to track it per stage.

JJulien Buteau
beginner8 min readMarch 14, 2026
Count the units that pass the first time.Illustrative batch
100units tested
92pass on the first attempt
8do not pass the first attempt
FIRST-PASS YIELD92 / 100 × 100 = 92%

Count each unit once, at its first attempt. A later passing retest does not improve this batch’s first-pass yield.

First pass yield (FPY) is the proportion of units that pass a test stage on their first attempt, with no rework and no retest. It is the most quoted number in manufacturing test and the most commonly miscounted.

The Definition

FPY = units passing on the first attempt / units entering the stage

The denominator is units that entered, not units that eventually passed. The numerator counts first attempts only. A board that fails, gets reworked, and passes on the second run is a failure for FPY purposes, even though it ships.

That last sentence is where most disagreements start, and it is the whole point of the metric. FPY measures whether the process produces good boards. Final yield measures whether you managed to ship them. Both are useful; conflating them hides the cost of rework.

FPY vs the Other Yields

MetricCountsAnswers
First pass yieldPassed first time / enteredIs the process right the first time?
Final yieldPassed eventually / enteredWhat fraction shipped?
Rolled throughput yield (RTY)Product of FPY across all stagesWhat fraction passes every stage untouched?
ThroughputUnits per hourIs the line fast enough?

A line with 92 % FPY and 99 % final yield is spending real money on rework that the final number conceals. See rolled throughput yield for the multi-stage view.

What Corrupts the Number

Retests overwriting the original. If a retest replaces the failing record, FPY silently becomes final yield. The failing run has to survive.

Operators re-running until it passes. Three attempts and a pass is not a pass; without the first two recorded, it looks like one.

Blending stages. ICT, FCT and final inspection have different failure modes. One combined number tells you the line is at 94 % and nothing about where to look.

Excluding "not real" failures. Fixture contact failures and operator errors are real production events that cost time. Excluding them from FPY produces a number that flatters the line and hides a fixture problem.

Counting reworked units as new units. A reworked board re-entering the stage inflates the denominator and makes yield look better with every rework cycle.

Getting It Right in the Data

FPY is a property of your records, not a calculation you apply afterwards. Three things have to be true.

One run per attempt. Every execution uploads, whether it passed or failed. Failures are data.

A stable unit identity. Every run carries the serial number, so attempt order per unit is reconstructable.

One procedure per stage. Separate procedures for ICT, FCT and final gives you three FPY numbers that mean something.

With those, FPY is a query rather than a spreadsheet exercise.

procedure.yaml
29 lines
name: PCBA Functional Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dmm    python: plugs.dmm:Multimetermain:  - name: Power Rails    python: phases.power_rails    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Quiescent Current        unit: mA        validators:          - operator: "<="            expected_value: 45.0
phases/power_rails.py
def power_rails(measurements, dmm):    measurements.rail_3v3 = dmm.read_voltage()    measurements.quiescent_current = dmm.read_current_ma()
plugs/dmm.py
class Multimeter:    def read_voltage(self) -> float:        return 3.31  # Replace with a real instrument read    def read_current_ma(self) -> float:        return 41.3

Run it with tofupilot run ./procedure.yaml --upload. Each attempt on a serial becomes its own run, in order, pass or fail.

Marking Retests Honestly

When a board is reworked and re-tested, say so on the run. That makes the distinction between first attempts and retests explicit rather than inferred:

phases/record_attempt.py
def record_attempt(run, attempt_type):    """attempt_type comes from an operator prompt: 'first' or 'retest'."""    run.metadata["attempt_type"] = attempt_type    run.metadata["rework_ref"] = ""

If the rework had a work order, record its reference too. A month later, "which rework action actually fixed anything" becomes answerable.

Reading FPY

QuestionWhat to look at
What is FPY this shift?First pass yield on the procedure, filtered by date
Which measurement costs the most units?Pareto of failing measurements
Is one station worse?FPY filtered by station or slot
Is a measurement drifting toward its limit?Control chart on that measurement
Are limits too tight for the process?Cpk per measurement
How much rework are we doing?FPY compared against final yield

The gap between FPY and final yield is the rework bill. If FPY is 92 % and final is 99.5 %, 7.5 % of production is being touched twice, and that cost is invisible in the final number.

A Falling FPY Is a Diagnosis Problem, Not a Number Problem

When FPY drops, the number itself tells you nothing about why. The useful sequence is:

  1. Which measurement started failing? The Pareto usually names one.
  2. Did the values shift, or did the spread widen? A shifted mean is usually a process or supplier change; a widened spread is usually a fixture or measurement problem.
  3. Is it one station or all of them? Filter by station. One station means fixture, cabling, or instrument calibration.
  4. When did it start? Line up the change against build records, paste lots, stencil changes, firmware releases.
  5. Were the limits changed? A tightened limit produces a yield drop with no process change at all, which is why procedure versioning matters.

If You Already Run OpenHTF

Existing OpenHTF suites produce the same per-attempt records on the TofuPilot CLI:

fct.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),)def phase_power(test):    test.measurements.rail_3v3 = 3.31test = htf.Test(phase_power, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.

Common Mistakes

MistakeConsequenceFix
Overwriting a failed run with the retestFPY silently becomes final yieldKeep every attempt as its own run
One blended yield across stagesNo idea which stage is failingOne procedure per stage
Excluding fixture failuresA fixture problem stays invisibleCount them; they cost real time
Counting reworked units in the denominator againYield improves with every rework cycleDenominator is units entering, once
No serial number on the runAttempt order cannot be reconstructedAlways identify the unit
Chasing the number instead of the measurementYield meetings with no root causeStart from the failing measurement's distribution

More Guides

Put this guide into practice