Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/noir-lang/noir/llms.txt

Use this file to discover all available pages before exploring further.

An unconstrained function in Noir is a function that executes outside the ZK constraint system. It produces witness values during proof generation but does not add any constraints to the circuit. The Noir runtime can run arbitrary logic — loops over dynamic bounds, branching, complex calculations — inside an unconstrained function, then use the results in constrained code where they are verified cheaply.

The unconstrained keyword

Mark a function as unconstrained by placing the unconstrained keyword before fn:
unconstrained fn my_hint(x: u64) -> u64 {
    // arbitrary computation here — no constraints generated
    x * x
}
Call it from constrained code using an unsafe block:
fn main(x: u64) -> pub u64 {
    // Safety: result is constrained below by asserting it equals x*x
    let result = unsafe { my_hint(x) };
    assert(result == x * x);
    result
}
The unsafe block is required and must be accompanied by a // Safety: comment explaining why the unconstrained call is sound. The compiler enforces this lint. Without a constraint on the return value, your proof proves nothing about the result.

Why unconstrained functions exist

Zero-knowledge constraint systems can only express computation as arithmetic gates. Some operations that are trivial to compute are expensive to express as constraints:
OperationAs constraintsAs Brillig
Integer divisionMany gates (Euclidean algorithm)One instruction
Square rootMany multiplications + range checksOne sqrt call
Byte decompositionMultiple bit-range constraintsBit shifts + masking
SortingComparison network (many gates)Standard sort
The strategy is: compute the result quickly in an unconstrained function, then verify the result cheaply in constrained code. Verification is almost always cheaper than re-computing.

Witness generation vs. proving

Understanding the two phases of Noir execution clarifies why this is safe:
  1. Witness generation — the prover runs the full Noir program (including unconstrained functions) on their private inputs to assign a concrete value to every wire in the circuit. Unconstrained code runs here.
  2. Proving — the backend generates a ZK proof that the witness satisfies all the ACIR constraints. Unconstrained code does not appear here.
The verifier only sees the proof and public inputs. They verify the constraints, not the computation that produced the witness. This is why the constraint you place around an unconstrained result is what actually provides security.

Brillig: the unconstrained VM

Unconstrained functions compile to Brillig bytecode rather than ACIR opcodes. The Brillig VM executes this bytecode during witness generation. Unlike ACIR, Brillig supports:
  • Loops over bounds not known at compile time
  • break and continue
  • Dynamic memory access
  • Arbitrary branching
The Brillig VM is embedded in the ACVM execution engine and runs as a subroutine when ACIR encounters a BrilligCall opcode. The outputs become witness values available to the surrounding ACIR constraints.

Examples

Byte decomposition

The naive constrained approach to converting a u72 to [u8; 8] uses bit-shifts and AND operations, which are expensive in a constraint system. Moving the decomposition to an unconstrained function and verifying the result by reconstruction saves significant gate count:
fn main(num: u72) -> pub [u8; 8] {
    // Safety: 'out' is properly constrained below in 'assert(num == reconstructed_num)'
    let out = unsafe { u72_to_u8(num) };

    let mut reconstructed_num: u72 = 0;
    for i in 0..8 {
        reconstructed_num += (out[i] as u72 << (56 - (8 * i)));
    }
    assert(num == reconstructed_num);
    out
}

unconstrained fn u72_to_u8(num: u72) -> [u8; 8] {
    let mut out: [u8; 8] = [0; 8];
    for i in 0..8 {
        out[i] = (num >> (56 - (i * 8))) as u8;
    }
    out
}
The constrained version generates ~3619 backend gates. The version with the unconstrained hint generates ~2902 — a reduction of more than 700 gates from this one change.

Division hint

Division is cheaper to verify than to compute. Calculate the quotient and remainder in Brillig, then assert the relationship holds:
unconstrained fn divide(a: u64, b: u64) -> (u64, u64) {
    (a / b, a % b)
}

fn verified_divide(a: u64, b: u64) -> (u64, u64) {
    // Safety: quotient and remainder are constrained by the assert below
    let (q, r) = unsafe { divide(a, b) };
    assert(b * q + r == a);
    assert(r < b);
    (q, r)
}

Square root hint

unconstrained fn sqrt_hint(n: Field) -> Field {
    // host computes the square root
    // (in practice, use std::field::sqrt or a loop)
    n // placeholder — real implementation computes sqrt
}

fn verified_sqrt(n: Field) -> Field {
    // Safety: result is verified by squaring below
    let s = unsafe { sqrt_hint(n) };
    assert(s * s == n);
    s
}

Security checks

The Noir compiler includes two passes that check whether unconstrained call outputs are properly constrained. Independent subgraph detection checks whether any values in the final circuit are disconnected from both the inputs and outputs — a sign of a missing constraint. This check runs by default and can be disabled with --skip-underconstrained-check. Brillig manual constraint coverage checks that every output of a Brillig call (including every element of array outputs) is involved in a constraint against either an input to that call or a constant. For example:
unconstrained fn factor(v0: Field) -> [Field; 2] {
    // ...
}

fn main(foo: Field) -> [Field; 2] {
    let factored = unsafe { factor(foo) };
    assert(factored[0] * factored[1] == foo); // both outputs are constrained
    return factored
}
Here factored[0] and factored[1] are both involved in the product assertion, so the call is considered properly covered. This check runs by default and can be disabled with --skip-brillig-constraints-check.
If the compiler reports **bug**: Brillig function call isn't properly covered by a manual constraint, add an assertion that ties every output of the unconstrained call to its input or a known constant.

When to use unconstrained functions

Use unconstrained functions when:
  • The computation is hard to express as constraints but easy to verify. Division, square roots, sorting, and byte decompositions all fall into this category.
  • You are implementing an oracle. Oracles are always declared as unconstrained because they fetch data from outside the circuit. See the oracles guide.
  • You want to branch on runtime values. Constrained code uses all branches; unconstrained code can short-circuit.
  • You need loops over runtime-determined bounds. Brillig supports this natively; ACIR requires loop unrolling at compile time.
Do not use unconstrained functions when the result does not need to be verified — if you’re not constraining the output, there is no point in including the value in the circuit at all.

Build docs developers (and LLMs) love