KicadGen: a small language for scripting KiCad schematics
v0.72.103.244
Table of Contents
- 1. Purpose of this document
- 2. The role this language plays in the larger toolchain
- 3. The five-minute mental model
- 4. Setting up a new schematic
- 5. Using a component: symbols and pins
- 6. Connecting things: wires and labels
- 7. The one direction that doesn't fully work
- 8. Placing things pin-adjacent
- 9. Routing around something in the way
- 10. Fitting the page
- 11. Two things worth knowing about your own environment
- 12. Checking your own work
- 13. What this language does not do
List of Listings
- Listing 1: Setting up a new schematic
- Listing 2: Writing the schematic at the end of the script
- Listing 3: Finding the library a symbol is in
- Listing 4: Extracting a symbol's pin offsets with the library's own function
- Listing 5: Registering a symbol and its pins in the PINS dictionary
- Listing 6: Placing an instance
- Listing 7: A direct wire between two pins
- Listing 8: A label for a net with many members
- Listing 9: Marking a pin as intentionally unused
- Listing 10: Annotation text
- Listing 11: A connection routed as an L-shaped path of two wires
- Listing 12: Pointing the example at the right symbol libraries
- Listing 13: Telling make which kicad-cli to run
1. Purpose of this document
kicad_gen.py is a small Python library — really a tiny domain-
specific language — for describing a KiCad schematic in code:
place a component, connect two pins, write the file. Any real
circuit written using this language (this project's own two
example circuits, ppexample_circuit1~/~ppexample_circuit2, or a
downstream consumer's own real, physically-designed circuit) is
written using exactly what's documented below.
This document is a practical reference for the language itself,
written so that a change to a circuit — add a part, rewire a
connection, move something — can be made directly. It does not
repeat the history of how each rule below was discovered; that
narrative, including every bug found along the way and exactly how
it was confirmed, lives in ResearchJournal.org's own entries and in
kicad_gen.py's own module docstring. Read this document to use
the language; read those if you want to know why a rule exists.
EXCISED 2026-08-17 from dudhya (old-smartphone revival), the
project kicad_gen.py was originally developed inside, along with
the rest of this project's own generic engine (a separate
extraction, 2026-08-14 – see ResearchJournal.org's own entries for
both dates). What was genuinely dudhya-specific in the original
version of this document (a concrete description of THAT project's
own real pipeline, its own real file paths and Makefile targets)
was updated and dispatched to dudhya's own DevelopmentStandard.org
rather than carried forward stale into this project; what remains
here is the language reference itself, generalized where an example
had named a specific downstream project's own file.
Everything here was true as of v0.72.103.244. Treat kicad_gen.py's
own docstring as the final authority if the two ever disagree —
code and comment can drift, and the code is what actually runs.
2. The role this language plays in the larger toolchain
kicad_gen.py is only ONE piece of this project's own larger
toolchain – worth understanding the whole shape before diving into
the language itself, since it explains WHY things are organized the
way they are. See Concept.org's own "Goal" section for the full
picture; briefly:
- A real netlist (loaded from an existing KiCad schematic via
netlist.py's ownload_from_schematic(), or built directly for a circuit that doesn't have a schematic yet, as this project's own two example circuits do) is the one, shared ground truth. kicad_layout.pyconsumes that netlist to produce a placed, wired, KiCad-valid schematic – entirely algorithmically, using THIS document's own language (Schematic~/~place()~/~label()~/ ~wire()) under the hood, but with every component's own position decided from the netlist rather than typed by hand.- A hand-written script (this document's own real subject) can ALSO consume that same language directly, choosing every coordinate deliberately – this is how this project's own two example circuits are built, and how any downstream consumer's own real, physically-designed circuit would typically be authored.
Learning this language well enough to hand-write or hand-edit a
circuit script is exactly the skill needed to read or extend
kicad_layout.py's own generator code too, since both ultimately
call the same place()~/~label()~/~wire() primitives – one calling
them with hand-chosen coordinates, the other with computed ones.
3. The five-minute mental model
A KiCad schematic is, underneath, a text file (S-expressions,
similar in spirit to Lisp) listing every symbol placed on the
sheet, every wire drawn between two points, and every text label.
Hand-writing that text file is exactly as tedious and error-prone
as it sounds — this project tried it and found several real bugs
the hard way (see ResearchJournal.org). kicad_gen.py exists so you
never write that text directly: you call Python functions, and the
library emits correct, verified S-expressions on your behalf.
Three ideas carry almost the whole language:
- Every component is a symbol you "place" at an (x, y) position on the sheet, in millimetres, optionally rotated. Placing it gives you back a small dictionary mapping each of its pin names to that pin's own absolute (x, y) position — already correctly computed, accounting for the component's rotation.
- Two things on the same electrical net are connected either by a real, drawn wire, or by two matching net labels. A drawn wire is for two things sitting close together, physically. A label is for a net with many members spread across the sheet (like ground), or for two things too far apart to sensibly draw a direct line between.
- A label is never placed exactly on a pin. It sits at the far end of a short stub wire leading away from the pin, so the schematic stays readable rather than looking like every pin has text glued onto it.
4. Setting up a new schematic
Listing 1 is how every script starts: the imports, then the schematic.
import sys, os
sys.path.insert(0, os.path.dirname(__file__)) # so "import kicad_gen" finds
# the library, from any directory
# you happen to run the script from
from kicad_gen import Schematic, write_project_file
sch = Schematic(
title="My circuit",
date="2026-08-17",
rev="1.0", # see "Versioning the schematic" below
company="my project",
comments=["One line per title-block comment slot (up to 4)."],
paper="A4", # "A4" or "A3"; see "Fitting the page" below
)
sch is the one object every other call in this language works
through. At the end of the script (see Listing 2):
root_uuid = sch.write("my-circuit.kicad_sch")
write_project_file("my-circuit.kicad_pro", root_uuid, "my-circuit.kicad_pro")
write_project_file makes the schematic open as a proper KiCad
project rather than a bare file — always call it alongside
sch.write(), passing the root_uuid that write() itself returns
(the project file needs to reference the schematic's own internal
identifier to associate the two correctly).
4.1. Versioning the schematic
Pass the real project version as rev, not a hand-typed guess —
it will drift out of sync otherwise. This project's own two
example circuits (ppexample_circuit1~/~ppexample_circuit2)
hardcode a fixed rev string, since they're synthetic and have no
real release cycle of their own; a real, downstream circuit script
should instead take it as a command-line argument or read it from
a real version file, the same way a project's own build system
already tracks its own version, rather than hardcoding a string
that will drift.
5. Using a component: symbols and pins
5.1. Finding a symbol and its pins
Every component you place has to already exist in KiCad's own
bundled symbol libraries (/usr/share/kicad/symbols/*.kicad_sym
on a normal install — see "Two things worth knowing about your
own environment" below if more than one KiCad version might be
involved). You need two things about a symbol before you can use
it: which library file it's in, and the (dx, dy) offset of each
pin you care about, relative to the symbol's own origin.
To find which library a symbol is in (see Listing 3):
grep -l '(symbol "Q_PMOS_GSD"' /usr/share/kicad/symbols/*.kicad_sym
(Don't guess the library from the part's kind — a generic
P-MOSFET symbol turned out to live under Transistor_FET, not the
Device library a similar-sounding resistor or capacitor lives
in, and this specific mismatch was found because it differed
between KiCad versions; see ResearchJournal.org.)
To find a pin's own (dx, dy) offset, open the symbol's block in
the library file directly and look for its (pin ... (at dx dy
rotation) ...) lines, then its (name "...") to see which pin
that is. For a symbol with very many pins, this is tedious by hand
— a short Python snippet using this library's own extraction
function is more reliable (see Listing 4):
import re
from kicad_gen import extract_and_qualify
block = extract_and_qualify(
"/usr/share/kicad/symbols/Interface_USB.kicad_sym",
"STUSB4500QTR", "Interface_USB:STUSB4500QTR")
pin_re = re.compile(
r'\(pin \w+ line\s*\(at ([\-\d.]+) ([\-\d.]+) (\d+)\)'
r'.*?name "(\w+)"', re.DOTALL)
for m in pin_re.finditer(block):
x, y, rot, name = m.groups()
print(name, x, y)
Important: these (dx, dy) values are in the symbol's own local
coordinate system, which uses Y-increases-upward — the exact
same numbers you read out of the library file are what you put in
the PINS dictionary below; place() does the (non-obvious)
conversion to the sheet's own Y-increases-downward convention for
you. Do not flip the sign yourself.
5.2. Registering the symbol and its pins
Once you know the library, the symbol name, and its pins, add an
entry to a PINS dictionary and write a small place() wrapper
(this project's own two example circuits, via example_pins.py,
are a real, working illustration of exactly this pattern – extend
it rather than duplicating the pattern from scratch) (see Listing 5):
PINS = {
# ... existing entries ...
"MyNewPart": {"PIN_A": (0, 3.81), "PIN_B": (0, -3.81)},
}
def place(lib_dir_name, bare_name, ref, value, x, y, rot=0):
full_id = sch.use_symbol(SYM_DIR, lib_dir_name, bare_name)
return sch.place(full_id, PINS[bare_name], ref, value, x, y, rot)
5.3. Placing an instance
Listing 6 places one resistor and gives its reference, value and position:
r1 = place("Device", "R", "R1", "10k", 70, 45)
r1 is now a dictionary: r1["1"] and r1["2"] are that specific
instance's own two pins' absolute sheet coordinates. Every later
wire() or label() call uses these, not the raw offsets from
PINS — the raw offsets are relative to the symbol's own origin
before placement; the dictionary place() returns is already in
real sheet coordinates.
rot (default 0) rotates the whole symbol — and correctly
rotates where each of its pins ends up too, worked out for you.
Valid values are 0, 90, 180, 270.
6. Connecting things: wires and labels
6.1. A direct wire, for two things sitting close together
Listing 7 joins the reset pin of a chip to the pin of a resistor next to it:
sch.wire(u1["RESET"], r1["2"])
Draws one straight line between the two given points. Use this only when the two points are genuinely close and a straight (or simple two-segment) line between them makes sense — see "Placing things pin-adjacent" below for why this matters, and "Routing around something in the way" for what to do when a straight line isn't safe.
6.2. A label, for a net with many members or far-apart connections
Listing 8 puts a label on a pin, and the same name on another pin joins the two:
sch.label(r1["1"], "GND", "up")
This is the one call that does the most work in this language, so
it's worth understanding exactly what happens: a short, fixed-
length stub wire is drawn from the pin in the given direction,
and a net label reading "GND" is placed at the far end of that
stub — never on the pin itself. Every other label() call
anywhere in the schematic using the same net name (here, "GND")
is understood by KiCad to be the same electrical net, with no
wire needed connecting them across the sheet.
direction is one of "right", "left", "up", "down" —
which way the stub (and the label sitting at its end) points,
using this project's own established vocabulary: up = toward
the top of the sheet (north), down = toward the bottom (south),
left*/*right = toward the west/east edge. Pick the direction
that leads away from the component's own body, into open space
— not back toward it, and not toward a neighbouring pin (see
the warning box below).
A real trap, found more than once: if a component has two pins that each get their own
label()call, their two directions must point away from each other, never toward each other. Two stubs pointing at each other converge in the small gap between the pins and cross — which KiCad treats as a genuine electrical junction, silently merging two nets that were never meant to be connected. This exact mistake happened on four different components in one pass alone. If you add a two-pin part with two labelled ends, work out which physical side each pin is actually on (see "Placing things pin-adjacent" below) and point each stub further in that same direction, not back toward its neighbour.
6.3. Marking a pin as intentionally unused
Listing 9 marks a pin as unused on purpose:
sch.no_connect(u1["SCL"])
Use this for a real pin that this specific circuit genuinely does not use — an unused status output, an optional programming pin, and so on — rather than leaving it with no wire and no label at all, which ERC will (correctly) flag as a mistake rather than an intentional choice.
6.4. Annotation text
Listing 10 adds a note to the sheet:
sch.text(20, 145, "A note, with\nembedded newlines if you like")
Plain text on the sheet, not connected to anything electrically — useful for build notes ("this pin goes to the phone's own battery connector", etc.). Embedded newlines are fine and render as real line breaks.
7. The one direction that doesn't fully work
Confirmed by direct measurement (rendering to SVG and checking
where the drawn geometry actually ends up relative to the label's
own anchor point, not just visual impression): "right", "left",
and "down" all place their label's text correctly, extending away
from the pin as intended. "up" does not — no combination of
settings tried gave a clean result; the text ends up leaning back
toward the pin rather than clearly above it. It still works
electrically (ERC has no complaint), it just doesn't look as
clean.
Practical guidance: use "up" when you must (a pin genuinely on
a component's own north/top edge, with no sensible alternative),
but if a different direction is equally natural for a given pin,
prefer it. This is an open problem, not a solved one — if you find
a fix, it belongs in kicad_gen.py's own label() docstring and
in DevelopmentStandard.org's Electronics section, not just in your
own circuit's script.
8. Placing things pin-adjacent
A recurring, specific goal across this project's own schematic reviews: place a component so the pin it connects to is genuinely close by, ideally aligned in at least one axis so the wire between them is a single straight line, not a diagonal one that might cross something else.
The technique: before deciding where to put a new component, place
the other end of its connection first, and look at that pin's own
computed absolute coordinates (the dictionary place() already gave
you). Then choose your new component's own placement so that,
after accounting for its own rotation, its relevant pin lines up in
X (for a vertical wire) or Y (for a horizontal one) with the target.
Concretely, for a multi-pin IC with several real pins sitting on its
own north/top edge: passives wired to those pins were each placed
north of the IC, at the same X as the specific pin each one wires
to — not south of it, which would have required each wire to
travel down and back up, crossing the IC's own body on the way.
Two closely-related things to check afterward, since getting the wire straight doesn't guarantee everything else is fine:
- Two components can end up nearly on top of each other if you align both with two pins that are themselves very close together (a real, confirmed case: two capacitors wired to two pins only 2.54mm apart on the same IC ended up only 2.5mm apart themselves). Fix by staggering along the other axis — keep the alignment that gives the short, clean wire, but give the two components real separation in the perpendicular direction. See "Checking your own work" below for the specific check that catches this.
- A straight wire toward a neighbour can pass through unrelated pins if those pins happen to share the same coordinate on the axis your wire runs along — common on a multi-pin IC, where several pins along one edge share an X or Y value. If this happens, route with two wire segments instead of one straight line (see next section).
9. Routing around something in the way
For a connection that can't safely be a single straight line —
because something else sits between the two points — draw it as
two (or more) separate wire() calls forming an L-shape (or a
longer path), moving through an intermediate point that avoids
whatever's in the way (see Listing 11):
reset_x, reset_y = u1["RESET"] r1_x, r1_y = r1["2"] sch.wire((reset_x, reset_y), (r1_x, reset_y)) # sideways first sch.wire((r1_x, reset_y), (r1_x, r1_y)) # then straight down
There is no automatic obstacle avoidance in this language — you choose the intermediate point yourself, based on where you know the obstacle to be.
10. Fitting the page
paper="A4" (297mm x 210mm) or "A3" (420mm x 297mm) in the
Schematic(...) constructor. A4 is preferred for anything that
reasonably fits — A3 was used briefly for an earlier, much more
loosely-spaced layout and found unnecessarily large once spacing
was tightened up. There is no automatic scaling or overflow
warning — if a component or a label ends up past the page edge,
KiCad will still happily export it (onto a second, mostly-empty
page, or simply clipped depending on the export), so check this
yourself (see "Checking your own work" below).
11. Two things worth knowing about your own environment
11.1. More than one KiCad version might be installed
If kicad-cli --version and the KiCad GUI you actually open
files in report different version numbers, or if a symbol lookup
fails with "substring not found" even though the symbol clearly
exists somewhere, this project's own sandbox hit exactly this: an
old, apt-installed KiCad 7 provided
/usr/share/kicad/symbols (the default path), while the actual
KiCad 10.0.5 in use for verification was a separately-downloaded
AppImage with its own, different bundled symbols — and one
symbol (Q_PMOS_GSD) had genuinely moved to a different library
file between the two versions.
Set the KICAD_SYM_DIR environment variable to override
SYM_DIR's default and point this tool at a specific symbol
source, e.g. a copy extracted directly from an AppImage
(./KiCad.AppImage --appimage-extract, then look under
squashfs-root/share/kicad/symbols) (see Listing 12):
KICAD_SYM_DIR=/path/to/correct/symbols python3 ppexample_circuit1
11.2. kicad-cli might not be on your PATH
If you're using a downloaded AppImage rather than an installed
package, KICAD_CLI (a Makefile variable, separate from the
Python-side KICAD_SYM_DIR above) needs to name two words —
the AppImage itself, then kicad-cli as the argument telling it
which of its bundled tools to run (see Listing 13):
make KICAD_CLI="/path/to/KiCad.AppImage kicad-cli" kicad-erc
12. Checking your own work
Never trust that a script ran without a Python error as evidence the result is correct — none of the checks below are optional, and each one has caught a real, previously-unnoticed bug at least once in this project's own history.
- Electrical correctness:
make kicad-erc(or directly,kicad-cli sch erc --severity-all -o report.rpt file.kicad_sch) — read the actual violation count and each specific message, not just whether the command exits successfully. - Wire-path collisions: two unrelated wires geometrically overlapping (not just sharing an intended endpoint) silently merges their nets, and ERC alone does not always make this obvious at a glance. A short script comparing every pair of drawn wire segments for this is worth keeping and re-running after any layout change — see ResearchJournal.org, 2026-08-03 entries, for a working example.
- Label coordinate collisions: two differently-named labels landing on the exact same point is a related, separate check.
- Component-body overlap: ERC has no concept of this at all — it is purely visual. Compare every pair of placed components' own coordinates directly; anything closer than roughly 12mm is worth a second look (found a real case at 2.5mm apart this way).
- Page bounds: sum up every placed coordinate (symbols, wires,
labels, text — but skip the
lib_symbolsblock itself, which is full of unrelated internal symbol-definition coordinates that have nothing to do with where things actually ended up on the sheet) and confirm the range fits inside the paper size with a reasonable margin.
None of these are built into this language automatically — they are separate, small scripts written and refined over this project's own history of finding bugs the hard way. Treat them as a checklist to run after any change, not a one-time thing done once and never repeated.
13. What this language does not do
Stated directly, since it would be easy to assume otherwise after reading this far: this is a tool for describing what connects to what, plus enough manual control over position and routing to produce something reasonably clean. It does not automatically:
- lay out a whole circuit well on its own when hand-written — every
position in a hand-written circuit script is exactly whatever was
typed, chosen deliberately by a real, iterative review process,
not computed by an algorithm (that's
kicad_layout.py's own, separate job – see "The role this language plays in the larger toolchain" above); - avoid every possible wire crossing or component overlap on its own — the checks above catch problems, they do not prevent them from being possible in the first place;
- check your circuit's own electrical design is correct (only that it's wired the way you told it to be) — that's still on you, the datasheet, and ERC's own rule set.
General-purpose, always-correct automatic schematic layout remains, as far as this project's own research has found, an unsolved problem in EDA tooling generally — not a gap specific to this small language. Expect to keep using the checklist above, and expect a genuinely new circuit's first layout attempt to need at least one further round of review.