Home  /  Blog

From the workbench

KiCad BOM Generator Script With Live Stock Checks

A KiCad BOM generator should be a script, not a spreadsheet: put a manufacturer part number (MPN) on every symbol, export the grouped bill of materials with kicad-cli sch export bom, then ask DigiKey and Mouser for live stock, lead time and lifecycle on each MPN before you order. If any line cannot cover the build, the script fails and nothing ships to the fab until you fix the schematic.

I’m Jason Ratzlaff. I design boards and the software that runs on them, and the most expensive delay I see on small hardware runs is rarely the layout. It is a regulator, a connector or a crystal that was in stock the week the BOM was typed up and gone the week the order went in. This post is the workflow I use to stop that: where the data lives in KiCad, the exact export command, the distributor API calls, a grading method, alternates, and a gate that blocks the order.

Why Hand-Made BOMs Rot

A bill of materials built by copying values out of the schematic into a spreadsheet is correct for exactly one moment. After that, three things drift apart: the schematic, the spreadsheet, and the distributor shelves.

The schematic moves, the spreadsheet does not

Every late change, a resistor value tweaked during bring-up, a footprint swapped from 0603 to 0402, a pull-up added to fix a boot strap, has to be retyped by hand. Miss one and the assembler builds the old design. Reference designators are the worst: a re-annotate renumbers dozens of parts and silently breaks every row keyed on them.

Stock is a live number, not a property of the part

The quantity a distributor shows is a snapshot. A part that had tens of thousands on the shelf when you chose it can be backordered by the time the board is routed, and the page will then show a factory lead time measured in weeks or months. A spreadsheet has no way to notice. The only fix is to ask again, automatically, right before you commit money.

Breadboard prototypes wired up next to a digital multimeter during bring-up
Photo by Nicolas Thomas on Unsplash

Hand-made BOM

spreadsheet copied from the schematic

Source of truth
Two copies
After a change
Retype by hand
Stock check
Once, at part choice
Obsolete parts
Found at order time
Second source
Undocumented

Scripted BOM

kicad-cli export plus live API check

Source of truth
One: the schematic
After a change
Re-run the export
Stock check
Every run, live
Obsolete parts
Flagged before ordering
Second source
AltMPN, auto-checked
Illustrative diagram of a scripted KiCad BOM pipeline: schematic fields, kicad-cli export, DigiKey and Mouser API lookups, grading, and a pre-order gate that loops back to the schematic on failure
Illustrative: the whole pipeline in one picture. A failing line sends you back to the schematic, never around the gate.

How to Generate a BOM in KiCad That a Script Can Trust

The export is only as good as the fields behind it. KiCad symbols carry Reference, Value, Footprint and Datasheet by default, and you can add any custom field you like. For purchasing, the value “10k” is not enough: a script needs an exact orderable part.

Add MPN and Manufacturer fields to every symbol

I add the same small set of custom fields to every placed symbol and fill them in through the Symbol Fields Table (in the schematic editor’s Tools menu), which shows the whole design as one editable grid. That grid is the fastest way to see blank MPN cells before they become a problem.

Field What goes in it, and why the script needs it
MPN The manufacturer’s exact orderable part number, including packaging and temperature suffixes. The key for every distributor lookup.
Manufacturer The maker’s name as distributors list it. Disambiguates MPNs that collide across makers.
AltMPN A pre-approved drop-in second source, or blank. Checked automatically when the primary is at risk.
LCSC The LCSC / JLCPCB part number, if the board is assembled there. JLCPCB’s assembly BOM expects this column.
DNP attribute KiCad’s built-in “Do not populate” flag. Lets the export drop unfitted parts cleanly.
Put the MPN on the symbol, not in a side file.

A lookup table that maps “10k 0402” to a part number is a second source of truth, and it drifts exactly like a spreadsheet. When the MPN lives on the symbol, copying a sub-circuit into the next design carries the orderable part with it.

Export with kicad-cli sch export bom

Current KiCad ships a command-line BOM exporter, documented in the KiCad 9.0 CLI reference. It reads the schematic directly, so there is no GUI step to forget. The options that matter for purchasing are --fields (an ordered list of fields, where ${QUANTITY} and ${DNP} are KiCad’s computed columns), --labels for the header names, --group-by to merge identical parts into one row, --exclude-dnp, and --string-delimiter. That last one is easy to miss: fields are unquoted by default, and grouped references such as C1,C2,C7 contain the same comma as the column separator.

Terminal: export a grouped, purchasing-ready BOM from the schematic
# Export a grouped, purchasing-ready BOM straight from the schematic.
# --string-delimiter quotes grouped refs like "C1,C2,C7" so the CSV stays parseable.
# Single quotes matter: ${QUANTITY} and ${DNP} are KiCad variables, not shell ones.
kicad-cli sch export bom board.kicad_sch \
  --output build/board-bom.csv \
  --fields 'Reference,Value,Footprint,MPN,Manufacturer,AltMPN,LCSC,${QUANTITY},${DNP}' \
  --labels 'Refs,Value,Footprint,MPN,Manufacturer,AltMPN,LCSC,Qty,DNP' \
  --group-by 'MPN,Value,Footprint' \
  --sort-field 'Reference' --sort-asc \
  --string-delimiter '"' \
  --exclude-dnp

Grouping by MPN plus Value plus Footprint means ten identical 100 nF capacitors become one row with a quantity of ten, which is exactly what a distributor lookup wants: one query per unique part, not per reference designator. If you have saved a BOM preset in the schematic’s Symbol Fields Table, --preset and --format-preset will reuse it by name instead of spelling the fields out.

What about a KiCad BOM plugin?

The older flow is plugin-based: KiCad writes an intermediate XML file and a Python script turns it into whatever format you want. The CLI still supports that with kicad-cli sch export python-bom, which produces the XML for a custom script. I only reach for it when I need structure the CSV cannot express. Plugins such as InteractiveHtmlBom are excellent for hand assembly and inspection, but they answer “where does this part go”, not “can I buy it”. For purchasing, the plain CSV is the better input.

Checking Component Availability With the DigiKey API and Mouser API

Both large distributors publish free-to-register APIs that return stock and lead time per part. I query both because each one sees a different slice of the market, and a part that is thin at one is often healthy at the other. Their published limits shape how the script is written.

120DigiKey product calls per minuteProduct Information API, per DigiKey’s shared concepts
1,000DigiKey calls per daysame limit table; 429 when exceeded
30Mouser Search calls per minutewith 1,000 calls per day
10Mouser part numbers per requestpipe-separated, per the Search API schema

A typical board has a few dozen unique lines, so a grouped BOM fits comfortably inside a day’s quota even when you run the check several times. What does not fit is querying once per reference designator on a large design, which is one more reason to group before you look anything up.

How to use the DigiKey API (Product Information v4)

Register an organization and an app on the DigiKey developer portal and subscribe it to Product Information V4. That gives you a client ID and secret. Product lookups work with the 2-legged OAuth flow: POST the ID and secret with grant_type=client_credentials to https://api.digikey.com/v1/oauth2/token and use the returned bearer token.

The three endpoints the checker calls
POST https://api.digikey.com/v1/oauth2/token
     grant_type=client_credentials, client_id, client_secret

GET  https://api.digikey.com/products/v4/search/{productNumber}/productdetails
     Authorization: Bearer <token>
     X-DIGIKEY-Client-Id: <client id>
     X-DIGIKEY-Locale-Site: US
     X-DIGIKEY-Locale-Currency: USD

POST https://api.mouser.com/api/v1/search/partnumber?apiKey=<key>
     {"SearchByPartRequest": {"mouserPartNumber": "<MPN>",
                              "partSearchOptions": "Exact"}}

The call I use is ProductDetails (the second endpoint above), with the token plus an X-DIGIKEY-Client-Id header and locale headers for site and currency. It accepts a manufacturer or DigiKey part number and returns one product. The fields that matter here are QuantityAvailable, ManufacturerLeadWeeks, ProductStatus.Status, and the Discontinued and EndOfLife flags. The v4 API also exposes a Substitutions endpoint, which is a useful starting point when hunting for an alternate.

How to use the Mouser Search API

Request a Search API key from Mouser’s Search API page (it needs a My Mouser account). The key goes in the apiKey query parameter of the part-number search (the third endpoint above), and the body is a SearchByPartRequest whose partSearchOptions can be set to Exact. The published schema lists AvailabilityInStock, FactoryStock, LeadTime, LifecycleStatus, SuggestedReplacement and AvailabilityOnOrder (incoming quantities with dates) on each part. The field is named for Mouser numbers, but I pass the manufacturer part number and then keep only the result whose ManufacturerPartNumber matches exactly, because a search can return several related parts.

Stock is a snapshot, and neither API is a reservation.

A healthy number at 9 a.m. can be gone by the afternoon if a large buyer places an order. Run the check immediately before you pay, and when a line is thin, buy it first, before you place the board order that depends on it.

A KiCad BOM Generator Script, Written From Scratch

Here is the whole checker. It reads the CSV from the export above, looks up each MPN at both distributors, grades the line, tries the AltMPN if the primary fails, and exits with status 1 if anything is at risk. It needs only Python 3 and requests, and it keeps credentials in environment variables so nothing secret lands in the repo.

bom_check.py: grade every BOM line against live DigiKey and Mouser data
#!/usr/bin/env python3
"""Grade every line of a KiCad BOM against live distributor stock.

Usage:
  export DIGIKEY_CLIENT_ID=... DIGIKEY_CLIENT_SECRET=... MOUSER_API_KEY=...
  python3 bom_check.py build/board-bom.csv --boards 25

Exits 1 when any line is AT RISK, so make or CI can block the order.
"""
import argparse, csv, os, re, sys, time, urllib.parse
import requests

DK = "https://api.digikey.com"
MOUSER = "https://api.mouser.com/api/v1/search/partnumber"
DEAD = ("obsolete", "discontinued", "not recommended", "not for new designs",
        "end of life", "last time buy")


def digikey_token():
    r = requests.post(f"{DK}/v1/oauth2/token", timeout=20, data={
        "client_id": os.environ["DIGIKEY_CLIENT_ID"],
        "client_secret": os.environ["DIGIKEY_CLIENT_SECRET"],
        "grant_type": "client_credentials"})
    r.raise_for_status()
    return r.json()["access_token"]


def digikey(mpn, token):
    path = urllib.parse.quote(mpn, safe="")
    r = requests.get(f"{DK}/products/v4/search/{path}/productdetails", timeout=20, headers={
        "Authorization": f"Bearer {token}",
        "X-DIGIKEY-Client-Id": os.environ["DIGIKEY_CLIENT_ID"],
        "X-DIGIKEY-Locale-Site": "US",
        "X-DIGIKEY-Locale-Currency": "USD"})
    if r.status_code == 404:
        return None
    r.raise_for_status()
    p = r.json()["Product"]
    return {"src": "DigiKey", "stock": p.get("QuantityAvailable") or 0,
            "lead": p.get("ManufacturerLeadWeeks"),
            "status": (p.get("ProductStatus") or {}).get("Status") or ""}


def mouser(mpn):
    r = requests.post(MOUSER, params={"apiKey": os.environ["MOUSER_API_KEY"]}, timeout=20,
                      json={"SearchByPartRequest": {"mouserPartNumber": mpn,
                                                    "partSearchOptions": "Exact"}})
    r.raise_for_status()
    parts = (r.json().get("SearchResults") or {}).get("Parts") or []
    hit = next((p for p in parts
                if (p.get("ManufacturerPartNumber") or "").upper() == mpn.upper()), None)
    if hit is None:
        return None
    return {"src": "Mouser", "stock": int(hit.get("AvailabilityInStock") or 0),
            "lead": hit.get("LeadTime"), "status": hit.get("LifecycleStatus") or ""}


def weeks(text):
    """Lead times arrive as free text; normalize '12', '12 Weeks' or '84 Days' to weeks."""
    m = re.search(r"\d+", str(text or ""))
    if not m:
        return None
    n = int(m.group())
    return n / 7 if "day" in str(text).lower() else n


def grade(need, offers, low_x=3, long_weeks=8):
    live = [o for o in offers if o]
    stock = max((o["stock"] for o in live), default=0)
    dead = any(word in o["status"].lower() for o in live for word in DEAD)
    leads = [w for w in (weeks(o["lead"]) for o in live) if w is not None]
    if not live or stock < need or dead:
        return "AT RISK", stock
    if stock < need * low_x:
        return "LOW", stock
    if leads and max(leads) > long_weeks:
        return "LONG LEAD", stock
    return "IN STOCK", stock


def check(mpn, token):
    offers = [digikey(mpn, token), mouser(mpn)]
    time.sleep(2.1)  # stay under Mouser's 30 calls per minute
    return offers


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("bom")
    ap.add_argument("--boards", type=int, required=True)
    ap.add_argument("--attrition", type=float, default=0.05, help="extra for setup loss")
    args = ap.parse_args()
    token = digikey_token()
    failed = 0
    with open(args.bom, newline="") as f:
        for row in csv.DictReader(f):
            mpn = (row.get("MPN") or "").strip()
            need = int(int(row["Qty"]) * args.boards * (1 + args.attrition)) + 1
            if not mpn:
                print(f"AT RISK    {row['Refs']:<24} no MPN in schematic")
                failed += 1
                continue
            verdict, stock = grade(need, check(mpn, token))
            alt = (row.get("AltMPN") or "").strip()
            note = ""
            if verdict == "AT RISK" and alt:
                alt_verdict, alt_stock = grade(need, check(alt, token))
                note = f"  alternate {alt}: {alt_verdict} ({alt_stock} in stock)"
            print(f"{verdict:<10} {mpn:<28} need {need:>6}  best stock {stock:>8}{note}")
            failed += verdict == "AT RISK"
    sys.exit(1 if failed else 0)


if __name__ == "__main__":
    main()

How the quantity is computed

The script multiplies the per-board quantity by the number of boards, then adds an attrition allowance, 5% by default, because pick-and-place machines lose parts at feeder setup and on tiny passives. Assemblers publish their own loss rules, and some require extra reel length per feeder, so set --attrition to what your assembler actually asks for rather than trusting my default.

How to Grade Parts: In Stock, Low, Long Lead, At Risk

A single stock number does not tell you whether to order. I sort every line into one of four grades using three inputs: stock coverage (the best distributor stock divided by the quantity this build needs), manufacturer lead time, and lifecycle status. The thresholds are a method, not a law; tune them to your build size and risk tolerance.

Grade Rule in the script What I do
In stock Coverage at least 3x the build, lead time 8 weeks or less, active part Order with the board
Low Enough for this build, less than 3x Buy it first, today
Long lead Plenty now, but the factory quotes more than 8 weeks Buy extra, or qualify an alternate
At risk Not enough stock anywhere, or a lifecycle status such as obsolete, discontinued, not for new designs, last time buy or end of life Block the order; use the AltMPN
Illustrative scatter chart of hypothetical BOM lines plotted by manufacturer lead time and stock coverage, divided into in stock, long lead, low and at risk zones; not real part data
Illustrative only: hypothetical lines A to G showing how the thresholds divide the space. No real parts or stock figures are plotted.

Why a 3x coverage threshold? Because when a distributor has only a little more than you need, one other buyer’s order can take it to zero between your check and your checkout. Why flag long lead times on parts that are in stock today? Because a first build that succeeds is usually followed by a second one, and a part with a months-long factory lead time will stall that second build even though nothing looked wrong the first time.

Alternates and Second Sources

The cheapest time to find an alternate is while you are drawing the schematic, not the day the primary disappears. An alternate only helps if it is truly drop-in, so I hold every AltMPN to the same checklist before it goes into the field.

Tweezers placing a small black chip onto a green circuit board
Photo by Vishnu Mohanan on Unsplash

What makes an alternate truly drop-in

  • Same footprint and pinout. Same land pattern, same pin numbering, same exposed-pad connection. “Same package name” is not enough; check the mechanical drawing.
  • Electrical margins that still hold. Voltage and current ratings, tolerance, temperature coefficient, and for capacitors, the capacitance left after DC bias at your operating voltage.
  • Behavior at the edges. For regulators and oscillators: stability with your output capacitors, startup behavior, and enable thresholds. Two parts can share a pinout and behave very differently.
  • Different supply chain. A second MPN from the same maker built in the same fab protects you from less than a part from another manufacturer.

Passives are the easy case: common chip resistors and capacitors in standard sizes have many interchangeable sources, so some teams leave the MPN generic and let the assembler choose. I still record one real MPN, because the script needs something concrete to check. Where alternates are hard, a specific microcontroller, a sensor, a shielded inductor with a particular footprint, is exactly where the AltMPN field and a design-time decision pay off.

Checking JLCPCB and LCSC parts

If JLCPCB assembles the board, its KiCad BOM guide asks for a BOM with Comment, Designator, Footprint and the JLCPCB / LCSC part number, which is why the export above carries an LCSC column. Their stock is what the assembly line will actually pull from, so check it in the JLCPCB parts library during the order upload even when DigiKey and Mouser look healthy. I do not script this step; DigiKey and Mouser both publish open, documented APIs, and the JLCPCB upload screen shows shortages line by line before you pay.

A Pre-Order Gate That Blocks Bad Orders

The checker is only useful if it runs every time, so I wire it into the build rather than relying on memory. In a Makefile, the order target depends on the stock gate, and the stock gate fails on any at-risk line.

Makefile: the order target cannot run until the stock gate passes
# Makefile: the order target refuses to run until the BOM passes the stock gate
BOARDS ?= 25

build/board-bom.csv: board.kicad_sch
	mkdir -p build
	kicad-cli sch export bom $< --output $@ \
	  --fields 'Reference,Value,Footprint,MPN,Manufacturer,AltMPN,LCSC,$${QUANTITY},$${DNP}' \
	  --labels 'Refs,Value,Footprint,MPN,Manufacturer,AltMPN,LCSC,Qty,DNP' \
	  --group-by 'MPN,Value,Footprint' --string-delimiter '"' --exclude-dnp

stock-gate: build/board-bom.csv
	python3 bom_check.py $< --boards $(BOARDS) > build/stock-report.txt; \
	  s=$$?; cat build/stock-report.txt; exit $$s

order: stock-gate
	@echo "Stock gate passed for $(BOARDS) boards. Upload build/ to the fab."
Illustrative output format: placeholder part numbers and quantities, not real stock data
$ make stock-gate BOARDS=25
IN STOCK   (MPN)                        need     27  best stock    41200
LOW        (MPN)                        need    132  best stock      310
LONG LEAD  (MPN)                        need     27  best stock     2600
AT RISK    (MPN)                        need     53  best stock       12  alternate (ALT MPN): IN STOCK (8800 in stock)
# exit status 1: make stops here and the order target never runs

The pre-order checklist

  1. Freeze the schematic. Tag the commit the boards will be built from, so the BOM and the fab files come from the same revision.
  2. Fill every MPN. Open the Symbol Fields Table and sort by MPN; any blank cell is a line the script will mark at risk.
  3. Export with kicad-cli. Run the export command so quantities, grouping and DNP handling come from the schematic, not from memory.
  4. Run the stock gate for the real quantity. Pass the number of boards you are actually building, plus the attrition your assembler requires.
  5. Resolve every at-risk line. Promote the AltMPN, redesign, or buy the part from another source, then re-run until the gate passes.
  6. Buy low-stock lines first. Order them before the board order, so a competing buyer cannot empty the shelf in between.
  7. Confirm at the assembler. Upload the BOM and placement file and read the assembler’s own shortage report before you pay.
  8. Archive the report. Commit stock-report.txt next to the fab files, so the next build starts with a record of what was risky last time.

If you are laying out the board as well, my notes on component placement in KiCad cover the step that comes just before this one.

Frequently Asked Questions

How do I generate a BOM in KiCad?

In the schematic editor, use the Symbol Fields Table or the BOM export to produce a CSV of fields such as Reference, Value, Footprint and quantity. For repeatable results, run kicad-cli sch export bom on the .kicad_sch file with –fields, –group-by and –exclude-dnp, so the BOM is regenerated from the schematic every time instead of edited by hand.

How do I export a BOM from KiCad for JLCPCB?

JLCPCB’s assembly BOM needs Comment, Designator, Footprint and the JLCPCB / LCSC part number. Add an LCSC field to your symbols, then export with kicad-cli, mapping Value to Comment and Reference to Designator with the –labels option, and check the assembler’s shortage report during upload.

What is the best KiCad BOM plugin?

It depends on the job. InteractiveHtmlBom is a popular choice for hand assembly and inspection because it shows where each part goes. For purchasing and stock checks, the built-in kicad-cli CSV export plus a small script is simpler and easier to automate than a plugin.

How do I get a DigiKey API key?

Create an account on the DigiKey developer portal, register an organization and an app, and subscribe the app to the Product Information V4 API. The portal then issues a client ID and client secret, which you exchange for a bearer token using the OAuth 2-legged client credentials flow.

What are the DigiKey API rate limits?

DigiKey documents a limit of 120 requests per minute and 1,000 per day for the Product Information API, and returns HTTP 429 when you exceed it. Grouping your BOM by MPN keeps a typical board well inside that budget.

How do I use the Mouser API to check stock?

Request a Search API key from Mouser, then POST a SearchByPartRequest to api.mouser.com/api/v1/search/partnumber with the key in the apiKey query parameter. Each returned part includes AvailabilityInStock, LeadTime and LifecycleStatus; Mouser allows 30 calls per minute and 1,000 per day.

How often should I check component availability?

Check when you choose parts, again when the design freezes, and always immediately before you pay for parts or assembly. Stock changes daily, so a check from last week is not a reason to skip the one today.

Need a board that ships on schedule?

I design hardware end to end, from schematic and PCB layout through firmware and edge-AI software, with a BOM that is checked against live stock before anything is ordered. Tell me what you are building.

Start the conversation