Fixed-Size Arrays in Cairo: Why You Can’t Index Them ?
This question hits at something fundamental about Cairo’s design. Let me explain why Cairo treats fixed-size arrays differently, and in doing so, reveal something deep about how proofs work.
Part 1: Let’s Start With What Actually Happens
In Most Languages
// Rust
let arr = [1, 2, 3, 4, 5];
let third = arr[2]; // Works! Gets 3
println!("{}", third);# Python
arr = [1, 2, 3, 4, 5]
third = arr[2] # Works! Gets 3
print(third)// JavaScript
const arr = [1, 2, 3, 4, 5];
const third = arr[2]; // Works! Gets 3
console.log(third);In Cairo (Early Versions)
// Cairo - This doesn't work!
let my_arr = [1, 2, 3, 4, 5];
let third = my_arr[2]; // Compile error!Why? That’s the question. And the answer is beautifully strange.
Part 2: Understanding the Two Different Things
Let me be very precise about what exists in Cairo:
Thing 1: Fixed-Size Arrays (Tuple Literals)
let fixed: [felt252; 5] = [1, 2, 3, 4, 5];
// This is a TUPLE. It's inlined. It's part of your code.Properties:
- Size is known at compile time
- Not stored in memory (well, sort of — see below)
- Cannot be indexed with variables
- Can destructure by position (if compiler allows)
Thing 2: Dynamic Arrays (Smart Contract Type)
let dynamic: Array<felt252> = array![1, 2, 3, 4, 5];
// This is an ARRAY. It's stored in memory. It's mutable.Properties:
- Size can change at runtime
- Stored in Cairo’s memory segment
- Can be indexed with runtime values
- Supports push, pop, append
These are fundamentally different things.
Part 3: Why Can’t You Index Fixed-Size Arrays? The Real Reason
This is where most explanations fail. Let me give you the true reason:
The Problem: Runtime vs. Compile-Time
When you write:
let arr = [1, 2, 3, 4, 5];
let index: felt252 = some_runtime_value;
let element = arr[index]; // What does this mean?You’re asking Cairo to do something that doesn’t fit the proof model.
Let me show you why:
How Array Indexing Works in CPU Languages
CPU Memory:
┌────────────────┬────────────────┬────────────────┬────────────────┬────────────────┐
│ Address 0x100 │ Address 0x108 │ Address 0x110 │ Address 0x118 │ Address 0x120 │
├────────────────┼────────────────┼────────────────┼────────────────┼────────────────┤
│ 1 │ 2 │ 3 │ 4 │ 5 │
└────────────────┴────────────────┴────────────────┴────────────────┴────────────────┘
Element 0 Element 1 Element 2 Element 3 Element 4Runtime indexing:
index = 2
address = base_address + (index * element_size)
address = 0x100 + (2 * 8) = 0x110
Load value at 0x110 → 3The CPU computes a memory address at runtime and loads from it. Simple. Direct. Fast.
How STARK Proofs Work
STARK Proof:
┌─────────────────────────────────────────┐
│ Execution Trace (all steps recorded) │
├─────────────────────────────────────────┤
│ Step 0: x = 1 │
│ Step 1: y = 2 │
│ Step 2: z = x + y = 3 │
│ Step 3: result = 5 │
│ ... │
└─────────────────────────────────────────┘Every step must be proven mathematically.Here’s the problem:
When you index with a runtime value, the prover must prove which branch was taken.
let arr = [10, 20, 30];
let index = get_runtime_index(); // Could be 0, 1, or 2let element = arr[index]; // Which element? Depends on runtime input!From the prover’s perspective:
If index = 0: element = 10
If index = 1: element = 20
If index = 2: element = 30But which one actually happened?
The prover must prove the SPECIFIC execution.This is not a problem for CPUs — they just load whatever’s at that address. But STARKs must prove every step deterministically.
The Fundamental Issue: Non-Deterministic Memory Access
When you use runtime indexing on a fixed array:
let arr = [10, 20, 30];
let index: felt252 = runtime_value;
let element = arr[index];The STARK proof must encode:
- Which element was accessed
- That it was the correct element for that index
- That the index was in bounds
- That the computation was done correctly
This becomes complex when the array is fixed-size and inlined in the code.
Part 4: The Technical Reason (Memory Layout)
Let me explain how Cairo actually stores these two different types:
Fixed-Size Arrays: Tuples (Inlined)
let arr = [1, 2, 3, 4, 5];What happens:
Cairo Compilation:
┌─────────────────────────────────────────┐
│ Code: let arr = [1, 2, 3, 4, 5] │
│ │
│ Compiler sees: Fixed tuple of 5 values │
│ │
│ Memory representation: │
│ These are inlined constants in the │
│ execution trace, not stored in a │
│ sequential memory segment. │
│ │
│ arr.0 = 1 (directly accessible) │
│ arr.1 = 2 (directly accessible) │
│ arr.2 = 3 (directly accessible) │
│ arr.3 = 4 (directly accessible) │
│ arr.4 = 5 (directly accessible) │
└─────────────────────────────────────────┘The key point: Each element is directly bound to a variable position in the trace. There’s no “memory segment” with a base address.
It’s more like:
// It's basically:
let arr_0 = 1;
let arr_1 = 2;
let arr_2 = 3;
let arr_3 = 4;
let arr_4 = 5;Each is a separate variable in the execution trace.
Dynamic Arrays: Array<T> (Stored in Segment)
let arr = array![1, 2, 3, 4, 5];What happens:
Cairo Compilation:
┌─────────────────────────────────────────────┐
│ Code: let arr = array![1, 2, 3, 4, 5] │
│ │
│ Compiler sees: Dynamic array type │
│ │
│ Memory representation: │
│ Array Segment (in Cairo VM memory) │
│ ┌──────────────────────────────────────┐ │
│ │ Offset 0: 1 │ │
│ │ Offset 1: 2 │ │
│ │ Offset 2: 3 │ │
│ │ Offset 3: 4 │ │
│ │ Offset 4: 5 │ │
│ └──────────────────────────────────────┘ │
│ │
│ arr = ArrayPointer { │
│ segment: ArraySegment, │
│ offset: 0, │
│ length: 5 │
│ } │
└─────────────────────────────────────────────┘The key point: The array is stored in a contiguous segment with a base offset and length.
Runtime indexing makes sense here:
let index = runtime_value;
let element = arr.at(index);// Compiler generates: Load from (base_offset + index)
// With range check: Ensure index < length
// This is provable!Part 5: Why Fixed Arrays Can’t Be Indexed (The Proof System Reason)
Let me show you the exact problem:
The Problem: Non-Deterministic Branching
When you try to index a fixed array with a runtime value:
let arr = [10, 20, 30];
let index = get_user_input(); // Could be 0, 1, or 2
let element = arr[index]; // Which one?To prove this, Cairo would need to do something like:
// Hypothetical (this doesn't exist):
if index == 0 {
element = 10;
} else if index == 1 {
element = 20;
} else if index == 2 {
element = 30;
}But here’s the problem: This creates multiple execution paths.
A STARK proof must prove one specific execution. The proof system needs to know:
- What is the exact execution trace?
- Does it satisfy all constraints?
With branching based on a runtime value, the prover faces a choice:
Should I prove:
Path A: index = 0, element = 10
Path B: index = 1, element = 20
Path C: index = 2, element = 30
If I prove Path A but index was actually 1, the proof is wrong!This is solvable but expensive. You’d need to prove:
- That index is in the valid range
- The element at that index
- That the lookups are correct
This is doable for small fixed arrays, but it’s not elegant.
Cairo’s Solution: Don’t Support It
Rather than support this complex case, Cairo says:
“Fixed arrays are for compile-time known sequences. Use dynamic arrays if you need runtime indexing.”
This keeps the type system clean and simple.
Part 6: How Memory Management Differs
Now let’s compare the memory management models:
Fixed-Size Array Memory Management
let arr = [1, 2, 3, 4, 5];Storage:
- Located in the execution trace
- Each element is a separate variable
- Size is known at compile time
- No memory segment allocation
Lifetime:
- Lives until end of scope
- No heap fragmentation (not on heap)
- No garbage collection needed
Access:
- Direct by position:
arr.0,arr.1, etc. - Cannot index with runtime values
Use Case:
// Good use case: Unpacking known values
let (x, y, z) = [10, 20, 30];
// Or:
let point: [felt252; 2] = [100, 200];
let x = point[0]; // Works if literal indexCompiler can optimize:
The compiler knows all 5 elements at compile time.
If you access element 2, it can hardcode: element = 3
No runtime overhead.Dynamic Array Memory Management
let mut arr = array![1, 2, 3, 4, 5];Storage:
- Located in Cairo VM’s Array Segment
- Stored sequentially: offset 0, 1, 2, 3, 4
- Size can change at runtime
- Managed by Cairo VM
Lifetime:
- Lives until dropped explicitly
- Can be grown with
append - Can be shrunk with
pop
Access:
- Runtime indexing:
arr.at(index) - Can use variables for index
- Bounds checked automatically
Use Case:
// Good use case: Processing runtime data
for i in 0..arr.len() {
let element = arr.at(i); // Works
}let index = get_runtime_index();
let element = arr.at(index); // WorksRuntime overhead:
The VM must:
1. Check index < length
2. Compute offset: base + index
3. Load from segment
4. Include in proofPart 7: Why This Design Matters
The Core Principle: Separability of Concerns
Cairo distinguishes between:
Concern Fixed Array Dynamic Array Purpose Known-size data Variable-size data Indexing Compile-time Runtime Memory Inlined Segmented Proof Simple Complex but deterministic Performance Zero overhead Minimal overhead
Get Roger Zoe’s stories in your inbox
Join Medium for free to get updates from this writer.
This separation keeps each type optimized for its use case.
Comparison: Other Languages Handle This
Rust
// Fixed array: Can't really do runtime indexing
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let third = arr[2]; // Compile-time known index works// Variable index would need conversion:
let index = 2;
let third = arr[index]; // Still works in Rust!Rust can do both because Rust is a CPU language. CPUs don’t care — they just load memory.
Python
# Python treats both the same
arr = [1, 2, 3, 4, 5] # Actually a list
third = arr[2] # Runtime indexing worksPython unifies them because Python abstracts away memory management.
Cairo
// Fixed arrays: Can't runtime index
let fixed: [felt252; 5] = [1, 2, 3, 4, 5];
let third = fixed[2]; // Won't work with runtime value// Dynamic arrays: Runtime indexing works
let dynamic = array![1, 2, 3, 4, 5];
let third = dynamic.at(2); // Works with runtime valueCairo separates them because Cairo must be proof-friendly.
Part 8: How This Maps to the Proof System
Let me show you exactly how this affects STARK proofs:
Fixed Array: Proof is Simple
let arr = [10, 20, 30, 40, 50];
let x = arr.0; // Access element 0
let y = arr.2; // Access element 2Proof:
Step 1: arr = [10, 20, 30, 40, 50]
Step 2: x = 10
Step 3: y = 30
Step 4: DoneConstraints:
arr[0] = 10 ✓
arr[2] = 30 ✓
x = 10 ✓
y = 30 ✓Very simple!
Dynamic Array: Proof Has Range Checks
let mut arr = array![10, 20, 30, 40, 50];
let index = get_index(); // Runtime value
let element = arr.at(index);Proof must include:
Step 1: arr = [10, 20, 30, 40, 50]
Step 2: index = 2 (or whatever user provided)
Step 3: Verify: index < 5 (length check)
Step 4: Verify: arr[index] = arr[2] = 30
Step 5: element = 30Constraints:
arr exists in segment ✓
index < length ✓
Memory access is correct ✓
element = 30 ✓More complex, but deterministic!
If Fixed Arrays Supported Runtime Indexing (Hypothetically)
let arr = [10, 20, 30]; // Fixed array
let index = get_index(); // Runtime value
let element = arr[index]; // HypotheticalProof would need to:
Step 1: arr = [10, 20, 30]
Step 2: index = ? (depends on execution)
Step 3: ???The proof needs to handle:
IF index = 0 THEN element = 10
ELSE IF index = 1 THEN element = 20
ELSE IF index = 2 THEN element = 30
ELSE ???This requires proving a CONDITIONAL.
Cairo can do this, but it's messier than:
- Just encoding which branch was taken
- Proving that branch
It’s possible but inefficient. Cairo chose not to support it.
Part 9: Real-World Example: Token Transfer
Let me show you how this matters in actual smart contracts:
Fixed Array (Good Use)
// Representing a fixed tuple of prices (ETH, USDC, DAI)
#[derive(Copy, Drop)]
struct Prices {
eth: u256,
usdc: u256,
dai: u256,
}fn example() {
let prices = Prices { eth: 2000, usdc: 1, dai: 1 };
// Access known fields
let eth_price = prices.eth; // Known at compile time
}Dynamic Array (Good Use)
// Processing user transfers (count unknown at compile time)
#[storage]
struct Storage {
transfers: Map<u256, Transfer>,
transfer_count: u256,
}impl TokenImpl {
fn process_all_transfers(self: @ContractState) {
let count = self.transfer_count.read();
// Loop through all transfers
for i in 0..count {
let transfer = self.transfers.read(i);
execute_transfer(transfer);
}
}
}Why?
- Fixed arrays are for compile-time known data
- Dynamic arrays are for runtime-variable data
Part 10: When Fixed Arrays CAN Be Indexed
I need to clarify: Fixed arrays CAN be indexed, but only with compile-time constant indices.
let arr = [10, 20, 30, 40, 50];// Works: Compile-time constant index
let second = arr[1];// Works: Unpacking
let (first, second, third, fourth, fifth) = arr;// Doesn't work: Runtime variable index
let index: felt252 = 2;
let element = arr[index];// Doesn't work: Runtime computed index
let element = arr[get_index()];
The key is: The index must be determinable at compile time.
Part 11: The Memory Management Model Comparison
Let me create a comprehensive comparison:
Fixed-Size Array Memory Model
┌─────────────────────────────────────────┐
│ Execution Trace │
├─────────────────────────────────────────┤
│ Variable: arr_0 = 1 │
│ Variable: arr_1 = 2 │
│ Variable: arr_2 = 3 │
│ Variable: arr_3 = 4 │
│ Variable: arr_4 = 5 │
│ │
│ Each element is a separate variable │
│ in the execution trace. No "buffer" │
│ to index into. │
└─────────────────────────────────────────┘Accessing:
arr.0 → Direct reference to arr_0
arr[1] → Compile error (no runtime indexing)
arr[compile_time_const] → Direct reference (if const)Dynamic Array Memory Model
┌─────────────────────────────────────────┐
│ Array Segment (Cairo VM Memory) │
├─────────────────────────────────────────┤
│ Offset 0: 1 │
│ Offset 1: 2 │
│ Offset 2: 3 │
│ Offset 3: 4 │
│ Offset 4: 5 │
│ │
│ arr = {segment, offset: 0, length: 5} │
│ │
│ This is a real buffer in Cairo's │
│ memory space. │
└─────────────────────────────────────────┘Accessing with runtime index:
arr.at(0) → Load from offset 0
arr.at(index) → Load from offset + index (with bounds check)
arr.at(computed_value) → Works with any runtime valueTuple Semantics (Fixed Array)
┌──────────────────────────────────────────────────┐
│ Fixed Array: Tuple Semantics │
├──────────────────────────────────────────────────┤
│ │
│ let arr = [1, 2, 3, 4, 5] │
│ │
│ This is equivalent to: │
│ let arr: (felt252, felt252, felt252, │
│ felt252, felt252) = (1, 2, 3, 4, 5) │
│ │
│ You can only access by position: │
│ arr.0, arr.1, arr.2, arr.3, arr.4 │
│ │
│ Or destructure: │
│ let (a, b, c, d, e) = arr │
│ │
│ You CANNOT: │
│ let index = 2 │
│ let x = arr[index] // Not a tuple syntax │
│ │
└──────────────────────────────────────────────────┘Array Semantics (Dynamic Array)
┌──────────────────────────────────────────────────┐
│ Dynamic Array: Sequence Semantics │
├──────────────────────────────────────────────────┤
│ │
│ let arr = array![1, 2, 3, 4, 5] │
│ │
│ This is a real Array<T> with: │
│ - Storage in a segment │
│ - Length tracking │
│ - Runtime indexing support │
│ │
│ You can: │
│ arr.at(0), arr.at(1), arr.at(index) │
│ arr.append(value) │
│ arr.pop_front() │
│ arr.len() │
│ │
│ You CANNOT: │
│ arr.0 (not a tuple) │
│ arr[2] (no bracket indexing, use .at()) │
│ │
└──────────────────────────────────────────────────┘Part 12: Why Static Types and Dynamic Arrays Are Different
This is the key insight:
Static Types (Fixed Arrays)
// Static: Size known at compile time
#[derive(Copy, Drop)]
struct Point {
x: felt252,
y: felt252,
}// Fixed array: Size known at compile time
let coordinates: [felt252; 3] = [100, 200, 300];// Compile-time union: Size known at compile time
let result: Result<u256, felt252> = Result::Ok(42);
Why?
- Everything is statically determined before execution
- No runtime allocation needed
- Compiler can optimize away all overhead
- Proof is efficient (no conditional branching)
Access Pattern:
- Positional (
.0,.1,.x,.y) - Cannot use runtime indices
- Safe because positions are fixed
Dynamic Types (Dynamic Arrays)
// Dynamic: Size determined at runtime
let list: Array<felt252> = array![1, 2, 3];// You can add to it at runtime
list.append(4); // Now it has 4 elements
list.append(5); // Now it has 5 elementsWhy?
- Size is not known at compile time
- Runtime allocation in Cairo’s segment
- Proof includes bounds checks (acceptable overhead)
- Cannot be optimized (size varies)
Access Pattern:
- Index-based (
.at(index)) - Runtime indices supported
- Safe because bounds are checked
Part 13: The Design Philosophy
Here’s the fundamental reason for Cairo’s design:
Goal: Make Proofs Efficient and Simple
Principle 1: Fixed data → Fixed proof
Fixed array of [1,2,3,4,5]
Proof: Just encode these constants
Overhead: NonePrinciple 2: Variable data → Conditional proof
Dynamic array with runtime index
Proof: Encode the index lookup
Overhead: Acceptable (bounds check + offset calc)Principle 3: Don't mix them
If fixed arrays supported runtime indexing,
every access would need conditional branches
Overhead: High, even when not needed
By separating fixed and dynamic arrays, Cairo keeps both fast:
- Fixed arrays: Zero-overhead access
- Dynamic arrays: Minimal-overhead access
If they were unified, both would pay the overhead cost.
Part 14: The Technical Problem Illustrated
Let me show you the exact problem that would occur:
If Fixed Arrays Supported Runtime Indexing
let arr = [10, 20, 30];
let index = get_user_input();
let value = arr[index];The prover would generate proof constraints:
For any valid index value (0, 1, or 2):
(index == 0 AND value == 10) OR
(index == 1 AND value == 20) OR
(index == 2 AND value == 30)This requires:
- Computing all three values (10, 20, 30)
- Comparing index to each
- Selecting the right value based on conditions
Compare to dynamic arrays:
let arr = array![10, 20, 30];
let index = get_user_input();
let value = arr.at(index);The proof verifies:
- index < length (bounds check)
- Load from arr[offset + index]
It’s simpler because:
- Array is designed for indexing
- Memory layout supports it directly
- No conditional branching needed
Part 15: Why Other Languages Do It Differently
Rust: Blurs the Line
// Rust: Static arrays ARE indexable with runtime values
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let index = 2;
let value = arr[index]; // Works fineWhy Rust can do this:
- Rust compiles to CPU instructions
- CPUs do memory loads directly
- No proof overhead
- No conditional branches in proof
JavaScript: Everything is Dynamic
// JavaScript: Everything is flexible
const arr = [1, 2, 3, 4, 5];
const index = 2;
const value = arr[index]; // Works, even though size can changeWhy JavaScript can do this:
- JavaScript abstracts away types
- Everything is optimized for runtime flexibility
- No static type system
- JIT compilation handles performance
Cairo: Prioritizes Proof Efficiency
// Cairo: Strict separation
let fixed = [1, 2, 3, 4, 5];
let index = 2;
let value = fixed[index]; // Error!let dynamic = array![1, 2, 3, 4, 5];
let value = dynamic.at(index); // WorksWhy Cairo requires this:
- STARK proofs need efficiency
- Conditional branching adds overhead
- Fixed arrays should have zero cost
- Dynamic arrays already pay the cost
Final Insight: What You Need to Know
When to Use Fixed Arrays
// Use when size is known at compile time
let config: [felt252; 3] = [1, 2, 3];
let x = config.0; // Use for unpacking
let (a, b, c) = config; // Do NOT try to runtime index
// let value = config[index];
When to Use Dynamic Arrays
// Use when size is not known
let mut data = array![];
data.append(user_input()); // Size grows
data.append(more_input());// Use for runtime indexing
for i in 0..data.len() {
let item = data.at(i);
}The Memory Management Model
Fixed Array:
Memory: Inlined in execution trace
Access: Tuple semantics (positional)
Proof: Efficient (no overhead)Dynamic Array:
Memory: Segment in Cairo VM
Access: Index semantics (runtime)
Proof: Efficient (minimal overhead with bounds check)This is a fundamental design difference rooted in proof efficiency, not an arbitrary limitation.





