MediumFixed-Size Arrays in Cairo: Why You Can’t Index Them ? | by Roger Zoe | Medium
Sitemap

Fixed-Size Arrays in Cairo: Why You Can’t Index Them ?

13 min readJan 17, 2026

--

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 4
Runtime indexing:
index = 2
address = base_address + (index * element_size)
address = 0x100 + (2 * 8) = 0x110
Load value at 0x110 → 3

The 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 2
let 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 = 30
But 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:

  1. Which element was accessed
  2. That it was the correct element for that index
  3. That the index was in bounds
  4. 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:

  1. What is the exact execution trace?
  2. 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:

  1. That index is in the valid range
  2. The element at that index
  3. 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 index

Compiler 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); // Works

Runtime overhead:

The VM must:
1. Check index < length
2. Compute offset: base + index
3. Load from segment
4. Include in proof

Part 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 works

Python 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 value

Cairo 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 2

Proof:

Step 1: arr = [10, 20, 30, 40, 50]
Step 2: x = 10
Step 3: y = 30
Step 4: Done
Constraints:
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 = 30
Constraints:
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]; // Hypothetical

Proof 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 value

Tuple 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 elements

Why?

  • 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: None
Principle 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:

  1. Computing all three values (10, 20, 30)
  2. Comparing index to each
  3. 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:

  1. index < length (bounds check)
  2. 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 fine

Why 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 change

Why 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); // Works

Why 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.

--

--