Circom, by example
A DSL primer told through one circuit — proving knowledge of a Poseidon pre-image. Every Circom keyword annotated as it appears, the constraint graph drawn out, and the R1CS fall-through to a witness.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/circom_by_example/
- FILED
- 2026-04-30 13:00 UTC
- REVISED
- 2026-04-30 13:00 UTC
- TIME
- 11 min read
- SERIES
- ZK SNARKs in production
- TAGS
There are two ways to write a zero-knowledge circuit. You can spell out the algebraic constraints by hand — (a) * (b) === c, one line per multiplication, every wire indexed manually — or you can write something that reads like a program and let a compiler emit the constraints. The first approach gives you total control and zero leverage. The second approach gives you Circom.
Circom is the DSL Iden3 designed in 2018 to make Groth16-style circuit authoring tractable. Six years later, in 2026, it is still the language most production ZK pipelines reach for first. The reason is not that it is the most expressive — Halo2 and the Noir frontend Aztec ships are both more powerful — but that its compilation target (R1CS) is the format every Groth16 toolchain on Earth speaks, and its tooling (snarkjs, circomlib, circomlibjs) is the deepest in the ecosystem.
This post is a walk through Circom from the inside out, told via one circuit: prove I know x such that Poseidon(x, 0) == y without revealing x. Pre-image of a hash. The “hello world” of shielded systems. By the end you’ll have read every Circom keyword that matters, seen the constraint graph it generates, and watched the witness get computed in your browser.
What R1CS actually is, in five paragraphs
Before any DSL, the substrate.
A rank-1 constraint system is a list of constraints of the form
where is the witness vector (every wire in your circuit, including inputs, outputs, intermediates, and a leading constant 1), and are constant vectors that pick out which wires participate in the i-th constraint. Every constraint is of the form (linear combination) × (linear combination) = (linear combination). Hence “rank 1”: each side is at most one multiplication.
What this means is: every constraint can express exactly one multiplication of two wires, plus arbitrary additions and constant scalings on either side. (2*x + 3*y) * (z) === w + 1 is one R1CS constraint. x * y * z === w is two — you need an intermediate t = x*y and then t * z === w. You can feel the shape of the cost function: addition is free, multiplication is expensive.
Why this exact shape? Because Groth16 (and its predecessors in the Pinocchio/QAP family) reduces an R1CS to a polynomial-divisibility check, and that reduction works exactly when each constraint is rank 1. The circuit’s number of constraints becomes the dominant factor in proof time and zkey size. Constraints, not wires, not gates.
In production Circom, you’ll see constraint counts ranging from ~50 (a single range check) to ~10,000 (a Merkle-32 path with Poseidon nodes) to ~2,000,000 (a circuit verifying an EVM block). Every increment is a multiplication that someone wrote, intentionally or not. A good Circom programmer thinks like an accountant.
The witness is generated outside the constraint system, by a witness-generator program the Circom compiler emits as WebAssembly. The constraint system checks the witness; it does not compute it. This separation is fundamental to how SNARKs work: prover knows everything, verifier checks much less.
A first circuit — knowledge of a Poseidon pre-image
pragma circom 2.1.5;
include "circomlib/poseidon.circom";
template KnowsPreimage() {
signal input x; // private witness — the value being hidden
signal output y; // public output — the published hash
component hash = Poseidon(2); // 2-input Poseidon hash gadget
hash.inputs[0] <== x; // wire x in
hash.inputs[1] <== 0; // pad with 0
y <== hash.out; // expose the result
}
component main { public [y] } = KnowsPreimage();
That’s the whole circuit. Every keyword, in order:
pragma circom 2.1.5— version pin. Circom is post-1.0; the language has minor breaking changes between minor versions, and circomlib’s gadgets target specific ranges. Pin or suffer.include "circomlib/poseidon.circom"— the include resolves against the--node-modulesflag orcircomlib’s install path. Includes are textual — there’s no module system in the npm sense, only file inclusion.template KnowsPreimage()— a parameterised circuit fragment. Templates are like generic functions: you instantiate them withcomponent foo = KnowsPreimage();. The lowercase/uppercase convention (Templates uppercase, components lowercase) is community style, not enforced.signal input x;— a wire that flows in to this template.signal output y;— flows out. Withoutinputoroutput,signal foo;is an internal wire.component hash = Poseidon(2);— instantiate a sub-circuit.Poseidonis a template defined in circomlib; the(2)is its parameter (number of inputs). Components compose hierarchically; the compiler inlines them at constraint-emission time.hash.inputs[0] <== x;— the constraint operator.<==does two things at once: it (a) emits the R1CS constraint that wiresxandhash.inputs[0]are equal, and (b) marks the right-hand side as the source for witness generation (so the WASM witness generator knows to copyx’s value intohash.inputs[0]).y <== hash.out;— same operator, exposing the hash output.component main { public [y] } = KnowsPreimage();— the entry point. Thepublicannotation says: when the verifier checks the proof,yis the public input. Everything else (here, justx) is private to the prover.
Three operators every Circom programmer types daily:
| Operator | What it does | Witness side | Constraint side |
|---|---|---|---|
<-- | witness only | assigns | no constraint emitted |
=== | constraint only | no witness assignment | emits constraint |
<== | both | assigns | emits constraint |
<-- shows up when you compute something the constraint system can’t (square-root, division, lookup) and then post-hoc constrain it with ===. === shows up alone when the relationship is implicit and you want to assert it. <== is the day-to-day workhorse.
What that circuit compiles to
The Circom compiler (circom2) emits four artifacts:
circuit.r1cs— the constraint system, in a binary format the rest of the toolchain consumes.circuit.wasm— the witness generator, a WASM module that takes the inputs as JSON and returns the witness vector.circuit.sym— symbol table mapping wire indices back to source-code names. Invaluable for debugging.circuit.json(optional,--json) — the constraint system in human-readable JSON. Slow to parse; useful for one-off inspection.
For our pre-image circuit, the R1CS file contains roughly the constraints below — Poseidon’s S-box rounds, MDS multiplications, output binding. The constraint graph looks like this:
flowchart TB X[private input x] --> H0[Poseidon input 0] Z[constant 0] --> H1[Poseidon input 1] H0 --> S1[round 1 S-box] H1 --> S1 S1 --> M1[MDS mix] M1 --> S2[round 2 S-box] S2 --> M2[...64 more rounds...] M2 --> O[Poseidon output] O --> Y[public output y] classDef pub fill:#0a4014,stroke:#4ade80,color:#fff classDef priv fill:#3a0a0a,stroke:#f87171,color:#fff class Y pub class X priv
Total constraint count for KnowsPreimage against BN254-Poseidon-128 with : 243 constraints for the hash, plus ~3 for the input wiring. Call it 246 R1CS constraints. snarkjs Groth16 will prove it in under 80 ms in a browser, including witness generation. (Numbers from Proving in the browser, by the numbers.)
Compile and run, in your browser
circomlibjs ships a browser build that includes the witness generator and the Poseidon constants without requiring you to install the Rust-based circom2 compiler. Below is a Sandpack node template that takes the inputs to our circuit, computes the witness, and emits the expected hash. It’s not a full proof — the proving step needs the zkey, which is megabytes — but it’s the witness generation half of the pipeline, end-to-end, in a browser.
What you should see when this runs: a public output y that is the Poseidon hash of (1234567890, 0) over BN254. That value is what would be posted on-chain or shipped over the wire. The proof would convince the verifier that someone knew an x mapping to that y, without revealing x.
Some Circom patterns worth internalising
A handful of patterns recur across every real circuit. They’re idioms more than language features.
Bit decomposition. Circom doesn’t have a native < 2^n predicate. You decompose into bits and constrain each bit to be 0 or 1:
template Num2Bits(n) {
signal input in;
signal output out[n];
var lc1 = 0;
var e2 = 1;
for (var i = 0; i < n; i++) {
out[i] <-- (in >> i) & 1; // witness only
out[i] * (out[i] - 1) === 0; // constrain to 0 or 1
lc1 += out[i] * e2;
e2 *= 2;
}
lc1 === in; // re-aggregate must match
}
The <-- followed by === is the canonical witness-then-check pattern. The bits are computed outside the constraint system (you can’t shift in R1CS) and then constrained to be valid.
Conditional selection. R1CS has no if. You select between two values with a Boolean:
// out = sel ? a : b, where sel must be 0 or 1
out <== a + (b - a) * (1 - sel);
MUX trees. A common pattern in Merkle paths: at each level, pick the left or right sibling based on the path bit. circomlib’s MultiMux1 template does this efficiently for t-element vectors.
Circom vs the alternatives in 2026
| Option | Cost | Latency | Blast radius | Notes |
|---|---|---|---|---|
| Circom (R1CS / Groth16) | Mature, big circomlib, 6 years of audits | Per-circuit trusted setup; proving is fast | Medium — language warts, but well-understood | Default for any Groth16 deployment in 2026 |
| Halo2 (PLONKish / KZG or IPA) | Steeper learning curve; chips/regions/lookups | Universal SRS, slower per-shot, lookup-friendly | PSE fork in maintenance; Axiom fork active | Pick this when the circuit is dominated by lookups |
| Noir (Aztec) | Higher-level Rust-like syntax; ACIR backend | Backend-agnostic; pluggable to PLONK or Honk | Newer; growing fast | What I'd pick for a greenfield 2026 project |
| arkworks (R1CS via traits) | Library, not a DSL; circuits are Rust types | Backend-agnostic; great for research | Code = circuit; the abstraction is leaky in practice | Where the academic implementations live |
The case for Circom in 2026 is one word: circomlib. Six years of accreted gadgets — Poseidon, MiMC, Pedersen, EdDSA, Merkle, range checks, Sigma protocols, set membership — that all interoperate cleanly because they target the same R1CS-over-BN254 substrate. The case against is also one word: expressivity. Circom is a templating engine over arithmetic constraints. It can’t loop over a runtime-known length, can’t recurse, has no first-class strings or arrays beyond fixed-size. For complex circuits the workarounds get baroque.
Inside zera-sdk we use Circom for the deposit / transfer / withdraw circuits because circomlib’s Poseidon and MerkleTreeChecker gadgets are fight-tested and because snarkjs is the only browser prover that ships in a single npm install. The day we need lookups (or recursion) at scale, the discussion is Halo2 vs Noir, not Circom.
What I would change if I were Circom 2.5
Three things, ranked by how much I’d actually use them.
- First-class lookup tables. Halo2 has them and they cut range-check costs by orders of magnitude. Plookup-as-a-tagged-include in Circom would close most of that gap.
- Module system.
includeis textual. Circular includes silently drop. A real module graph with explicit exports would prevent a class of bug I see in every audit. - Compiler-level constraint optimisation. The compiler already does basic linear-combination flattening. Aggressive common-subexpression elimination across templates would shave 10–20% off circomlib’s bigger gadgets at zero source-code cost.
None of these are coming, as far as I can tell. The Iden3 team has moved most of its energy to Polygon ID and the Circom roadmap has been relatively quiet through 2025–2026. That’s fine — the language is done in the way that good DSLs eventually become done. If you want what comes next, you go look at Noir and Halo2.
Further reading
- Circom 2 documentation — the canonical language reference
- CIRCOM: A Robust and Scalable Language for Building Complex Zero-Knowledge Circuits — Bellés-Muñoz, Isabel, Muñoz-Tapia, Rubio, Baylina (2022)
iden3/circom— the compiler sourceiden3/circomlib— the gadgets library that makes Circom usable in productioniden3/circomlibjs— JavaScript port of the cryptographic primitives, what the Sandpack above uses- Poseidon, by hand and by code — what the hash gadget actually is
- Proving in the browser, by the numbers — what happens after
circomfinishes compiling