How Monty Limits Stack Depth: Function Calls and Data Structure Recursion

Monty limits stack depth through two complementary mechanisms: ResourceTracker::check_recursion_depth guards Python function call frames with a default limit of 1000, while DepthGuard protects container operations like repr and eq with limits of 500 (release) or 100 (debug).

The pydantic/monty Python virtual machine implements strict safeguards against stack overflow and infinite recursion. Unlike CPython's single recursion counter, Monty separates protection between function-call recursion (the Python stack) and data-structure recursion (deeply nested containers). This dual-layer approach prevents both VM crashes from deep call stacks and hangs from circular or deeply nested object representations.

Function-Call Stack Limits in Monty

Monty tracks every Python function call through a dedicated resource management system that enforces hard limits on stack depth before new frames are allocated.

The ResourceTracker Interface

At the core of Monty's recursion protection is the ResourceTracker trait defined in crates/monty/src/resource.rs. This interface requires implementors to provide a check_recursion_depth method:

fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError>;

The method receives the current stack depth (measured as the number of active call frames) and returns Ok(()) if the call may proceed, or Err(ResourceError::Recursion) if the limit is exceeded.

Default Recursion Limits and Configuration

Monty provides two tracker implementations with different enforcement strategies:

NoLimitTracker enforces the CPython-compatible default of 1000 recursive calls, matching the behavior developers expect from standard Python:

const DEFAULT_RECURSION_LIMIT: usize = 1000;
if current_depth >= DEFAULT_RECURSION_LIMIT {
    return Err(ResourceError::Recursion);
}

In debug builds, Monty automatically lowers this limit to prevent stack overflow within the Rust VM itself before the Python-level check triggers.

For custom requirements, ResourceLimits allows programmatic configuration:

pub fn max_recursion_depth(mut self, limit: Option<usize>) -> Self {
    self.max_recursion_depth = limit;
    self
}

Passing None disables the limit entirely (not recommended for production), while specific values override the 1000-call default.

Enforcement at the Call Site

The actual enforcement occurs in crates/monty/src/namespace.rs within the push_frame method. Before allocating a new Python call frame, Monty queries the current stack length and validates it against the tracker:

let current_depth = self.stack.len() - 1;
heap.tracker().check_recursion_depth(current_depth)?;

The same check appears when reusing existing frames (line 200). If check_recursion_depth returns an error, Monty aborts the call setup and propagates a ResourceError::Recursion, which converts to a Python RecursionError at the exception boundary (see ResourceError::into_exception in crates/monty/src/resource.rs lines 230-236).

Data Structure Recursion Limits in Monty

Beyond function calls, Monty protects against deeply nested container objects that could cause stack overflow during operations like repr(), equality comparison, or hashing.

DepthGuard for Container Operations

Monty uses a DepthGuard struct (defined in crates/monty/src/resource.rs) to track remaining recursion budget for data-structure operations. Unlike the function-call tracker (which counts up), DepthGuard counts down from a maximum:

pub struct DepthGuard {
    depth_remaining: u16,
}

pub fn increase(&mut self) -> bool {
    // Returns true if depth remains, false if exhausted
}

pub fn increase_err(&mut self) -> Result<(), ResourceError> {
    // Returns Ok(()) or Err(ResourceError::Recursion)
}

pub fn decrease(&mut self) {
    // Restore the budget when exiting recursion
}

Container methods such as py_repr_fmt, py_eq, and py_hash accept a &mut DepthGuard parameter. Before recursing into child elements, they call guard.increase_err()?. If the guard returns an error, the operation aborts; otherwise, it proceeds and calls guard.decrease() when unwinding.

Configurable Limits for Debug and Release Builds

Monty adjusts data-structure limits based on build configuration to balance safety and functionality:

  • Release builds: MAX_DATA_RECURSION_DEPTH = 500
  • Debug builds: MAX_DATA_RECURSION_DEPTH = 100

These constants appear in crates/monty/src/resource.rs (lines 107 and 113). The lower debug limit prevents stack overflow during Rust development while still allowing reasonable nesting for testing.

When DepthGuard exhausts its budget, Monty handles the failure gracefully: representation methods typically truncate output with ... rather than crashing, while comparison or hashing operations raise RecursionError to signal the failure.

Practical Examples

Python Recursion in Practice

Consider a classic recursive Fibonacci implementation:

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

With Monty's default function-call limit of 1000, calling fib(1500) triggers a RecursionError before the Rust VM's native stack overflows. In debug builds, this protection kicks in earlier (typically around 100-200 calls) to safeguard the underlying Rust runtime.

Configuring Limits from Rust

When embedding Monty in a Rust application, customize the recursion boundaries during VM initialization:

use monty::resource::{ResourceLimits, LimitedTracker};

let limits = ResourceLimits::new()
    .max_recursion_depth(Some(2000))  // Double the default Python limit
    .max_allocations(Some(10_000));   // Additional resource constraints

let tracker = LimitedTracker::new(limits);
// Initialize Monty VM with custom tracker

The pydantic_monty Python wrapper exposes these same parameters through the Monty(..., limits=...) constructor, allowing Python developers to adjust limits without touching Rust code.

Summary

  • Function-call protection: Monty enforces a default limit of 1000 recursive Python calls (matching CPython) through ResourceTracker::check_recursion_depth, invoked in crates/monty/src/namespace.rs before pushing new frames. Debug builds use lower limits to protect the Rust VM.

  • Data-structure protection: Container operations use DepthGuard with limits of 500 (release) or 100 (debug) to prevent stack overflow during repr, eq, and hash operations on deeply nested objects.

  • Configurable limits: Both systems support customization via ResourceLimits::max_recursion_depth and DepthGuard initialization, allowing embedding applications to tune safety margins for their specific workloads.

Frequently Asked Questions

What is the default recursion limit in Monty?

Monty defaults to 1000 recursive function calls, identical to CPython's standard limit. This is enforced by the NoLimitTracker implementation in crates/monty/src/resource.rs. However, Monty also imposes a separate data-structure recursion limit of 500 in release builds (100 in debug) for container operations like repr() and equality comparisons.

How does Monty prevent stack overflow from deeply nested lists?

For data structures, Monty uses a DepthGuard mechanism rather than a simple counter. When performing recursive operations on containers (such as generating a string representation or checking equality), Monty passes a DepthGuard initialized to MAX_DATA_RECURSION_DEPTH (500 in release builds). Each recursive call consumes one unit from the guard; when exhausted, the operation either truncates (for repr) or raises RecursionError (for comparisons).

Can I disable the recursion limits in Monty?

Yes, though it is not recommended for production use. You can disable function-call limits by passing None to ResourceLimits::max_recursion_depth:

let limits = ResourceLimits::new()
    .max_recursion_depth(None);  // Disables the limit

However, disabling these protections risks stack overflow in the underlying Rust runtime, which typically aborts the process rather than raising a catchable exception. Data-structure limits can be effectively disabled by setting very high DepthGuard values, though the API expects a u16 limiting the maximum to 65,535.

Where does Monty check recursion depth during function calls?

The enforcement point is in crates/monty/src/namespace.rs within the push_frame method (line 164). Before allocating a new Python call frame, Monty calculates the current stack depth (self.stack.len() - 1) and invokes heap.tracker().check_recursion_depth(current_depth)?. If the tracker returns an error, the VM aborts the call setup and propagates a ResourceError::Recursion, which converts to a Python RecursionError at the exception boundary.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →