Blog ·

Why we stopped using KiCad as our PCB project format

Sergey Alekseev

For two weeks a PCBKit project was a KiCad project with a .pcbkit/ folder beside it. Since 2026-11-03 it is only the .pcbkit/ folder, every file in the PCBKit language. KiCad is the import and the export. Below is why we moved off KiCad as our file format, what the project files look like now and what the change measured. KiCad itself stays central to how we work. Its boards are our test corpus and its DRC is the judge in our benchmarks.

Why a PCBKit project was a KiCad project

The first layout (ADR 0017, 2026-10-21) had good reasons. Someone with a KiCad board could open their repository in PCBKit and keep working in the same files. KiCad could open them at any time. The design, the part of a board people and agents write, lived in .pcbkit/design.pcbkit. Everything the tools made from it (positions, copper, fills, the nets on pads, refdes, the chosen parts, the stack, the rules) lived in the .kicad_pcb, .kicad_pro and .kicad_dru, read into our model on every call and written back in place.

Before that, a board was a repository of our own files with six generated ones next to the design. People had to convert their KiCad board into a format they did not know and got back an export, not their files. Using KiCad as storage solved that.

What two homes for one board cost

It also meant every board had two sources that every call merged. project::merge reconciled the design with the KiCad board: positions the design pins and KiCad moves, parts only KiCad has, chosen parts kept as footprint fields, pin swaps kept as pad nets, the stack, the rules, the net classes. Each of those is a place where the two can disagree and a class of bugs lived there. On 2026-11-03 alone, a first build that had no .kicad_pcb yet ran no program, and a placement memo keyed by the serialized design dropped the facts read beside it.

Some concepts existed only because KiCad was the storage. Footprints carried pcbkit marks so we knew which parts the design owned. A board opened without the design that wrote it needed a release(). Our coordinates were KiCad’s drill origin. A person’s own copper was told apart from ours by uuids. The .kicad_pcb needed a generated-file guard and a fingerprint.

Then the cost. Every evaluation parsed the KiCad board through an s-expression importer of 10,947 lines of code. Every change edited that file losslessly. The web app kept KiCad text in its storage. The agents saw it too: what an agent changed and what the tools wrote were different files in different languages (ADR 0118).

The PCBKit project layout now

.pcbkit/design.pcbkit     the design: what the board is
.pcbkit/parts/, blocks/   modules the design uses
.pcbkit/board.pcbkit      the board as built, written by the tools
.pcbkit/comments.pcbkit   comments on the board
.pcbkit/cache/            what the tools derive (pour fills), git-ignored

board.pcbkit is the physical state in the language’s data subset: literals only, read from the tokens without evaluating anything. It holds where each component is, the nets on its pads as built (so the router’s pin swaps are there), the parts chosen for it, the tracks, vias and fixed copper. Lengths are in millimetres and angles in degrees, without units, one line per component, track and via. The example from the format reference:

pcbkit 2
// The board as built: written by PCBKit's tools (ADR 0118). Lengths in mm, angles in degrees.
layers = 4
components = {
  R1 = { at = (10.5, 3), rotation = 90, side = .bottom, path = "usb/R", footprint = "R_0603", nets = { "1" = "GND", "2" = "VBUS" } },
}
tracks = [
  { net = "GND", layer = "F.Cu", width = 0.25, points = [(1, 2), (3, 4)] },
]
vias = [
  { net = "GND", at = (3, 4), diameter = 0.6, drill = 0.3 },
]

It holds no intent. Every part, net, pour, class and rule is the design’s. The model is the design evaluated with this file’s positions and copper applied to the components it names. A component it doesn’t name is unplaced until the next build places it. There is nothing to merge.

Copper a person made carries by = .person instead of a uuid, so a build completes the routing around it and never redoes it. Copper a person locked says locked = true. Pour fills are not stored at all: a fill follows from the copper and the rules, so it is computed when needed and cached under .pcbkit/cache/fills/ by the hash of what it depends on.

Importing a KiCad board into PCBKit

pcbkit import x.kicad_pcb (or Import in the web app) converts the whole board. Its components go onto their own footprints in .pcbkit/footprints.pcbkit. Nets, classes, rules, KiCad’s settings and custom rules, the stack, the outline, pours, keepouts and art become design.pcbkit. Positions and copper become board.pcbkit with from = "kicad". With a schematic, the circuit its sheets draw becomes the design, each symbol a component of its part.

What our model doesn’t hold is not kept: drawings on user layers, dimensions, groups, the title block. The import’s answer lists what it left out (left_out), so nothing disappears silently.

A repository of KiCad files without a .pcbkit/ folder still works where you would expect it to. The CI gate, pcbkit check and the app opening a GitHub repository import it in memory for that run and check it as KiCad has it, with KiCad’s own fills. The first change written makes it a project.

Exporting back to KiCad and the round-trip gate

project.export { format: "kicad" } writes a KiCad project from the model (KiCad 8’s format, KiCad 10’s where footprints have jumper pads). The fab export works as before. The in-place writer that edited .kicad_pcb files is gone with its marks.

Because KiCad is no longer the project, the import and the export have to be exact on their own. The round trip is a test gate: every KiCad demo board goes import, our files, evaluation, export, re-import. The files must evaluate to the imported model while the re-import gives the same board. On the dev host kicad-cli judges our exports with KiCad’s own DRC on every make test. Over whole corpora, pcbkit-bench roundtrip compares IPC-D-356 pads and nets, connectivity and DRC of the original against our export. The details are in our testing notes and in the 1,554-board routing post, where every routed board goes back through KiCad 10’s DRC.

File size and read time, before and after

Measured on dev2 (AWS, 96 vCPU), --profile ci builds, one command at a time. Before is ae17de853, the main the work started from. Each board was built once with pcbkit route, then pcbkit check and pcbkit show ran three times each.

Board Board file before (.kicad_pcb) After (board.pcbkit) check, run 2, before → after
uc-ethernet-io (492 design lines) 902 KB 114 KB 5.35 → 5.08 s
bldc-controller (541 lines) 722 KB 55 KB 0.68 → 0.56 s
demo board (RP2040, 2.8″ panel) 177 KB 38 KB 0.89 → 0.81 s

The board file is 8, 13 and 4.6 times smaller. check got 5 to 18 % faster and show 1 to 9 %. That is less than the file sizes suggest, because on these boards most of a read is the language’s evaluation and the check itself, not the board file. A build costs the same within ±4 %, inside the run-to-run spread of the router’s work budget: it fills the pours of the placed board once more at its start. The first check after a build pays for the fills. Later ones read them from the cache.

What we gave up

Edits made in KiCad now come back only through another import. The old promise that KiCad opens the same files at any time is gone. Anything KiCad holds that our model doesn’t (user-layer drawings, dimensions, groups, the title block) does not survive the import. We converted the board library and the boards registry once with pcbkit import. Fixtures that had a .kicad_pcb now have a board.pcbkit.

We think that is the right trade for a tool where agents write the design. One file in one language is what they read and change. Two formats in two files, reconciled on every call, was a cost every request paid.

The same week: our own parts data

The day after, the parts side went the same way (ADR 0119). Part searches and every build’s lookups are answered from our own index of JLCPCB’s parts list, 7,038,913 rows of which 740,415 were in stock on 2026-11-04, walked daily and restocked hourly. Nothing is asked of a distributor while an agent waits. A part number JLCPCB doesn’t have is not orderable for assembly and the answers say so. The parts page has the rest.

The files are documented in full at /docs/format and the language they are written in at /docs/language. What the language checks as it compiles is in the post about typed nets.