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.