---
title: Run untrusted Python code safely with Vercel Sandbox
description: Execute untrusted and AI-generated code from Python in isolated microVMs, with files, processes, network policies, and snapshots.
url: "https://vercel.com/kb/guide/python-and-vercel-sandbox"
published: 2026-10-26
last_updated: 2026-10-26
authors: Ben Sabic
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

User-submitted scripts and AI-generated Python need an execution environment separate from your application. Running them with `exec()` or an unrestricted subprocess can expose your app's files and credentials.

Use the [Vercel Sandbox Python SDK](https://vercel.com/docs/sandbox/python-sdk-reference) to execute that code in an isolated Linux microVM. Your app uploads the files and starts a process, then retrieves its output.

You control how long the sandbox runs and which network destinations it can reach. Snapshots let you reuse a prepared Python environment with its dependencies already installed.

## What is Vercel Sandbox?

[Vercel Sandbox](https://vercel.com/docs/sandbox) runs code in isolated, on-demand Linux microVMs with separate kernels and filesystems. Keep application credentials outside the sandbox and set a network policy for the destinations its code may reach. The default policy allows outbound Internet access.

The [`vercel`](https://github.com/vercel/vercel-py) [package](https://github.com/vercel/vercel-py) exposes the Python SDK as `vercel.sandbox`, with async methods and a synchronous API for scripts and frameworks.

Your app never runs the untrusted code itself. It moves files in, starts processes, and reads results through the SDK, while a network policy decides what the sandbox can reach outside.

### The pieces you'll use

| Piece          | What it is                                          | In the SDK                                            |
| -------------- | --------------------------------------------------- | ----------------------------------------------------- |
| Sandbox        | A named microVM and its configuration               | `sandbox.create_sandbox()` returns a `box` handle     |
| Process        | A command running inside the sandbox                | `box.run_process()`, `box.create_process()`           |
| Filesystem     | The sandbox's files, read and written from your app | `box.fs.write_text()`, `box.fs.read_text()`, and more |
| Route          | A public URL for a port the sandbox exposes         | `ports=[3000]`, then `box.routes`                     |
| Network policy | Which outbound hosts the sandbox can reach          | `NetworkPolicy.deny_all()`, `.custom(...)`            |
| Snapshot       | A saved copy of a sandbox's filesystem              | `box.snapshot()`, `SnapshotSource`                    |

### Sandboxes and sessions

The sandbox has a stable identity and configuration. Its session is the period when its microVM is running, and only one session can run for a sandbox at a time.

Sessions default to a 5-minute execution limit. You can increase it to 45 minutes on Hobby or 24 hours on Pro and Enterprise.

Persistent sandboxes save their filesystem when a session stops and restore it on the next start. Files survive between sessions without ongoing compute, but running processes do not.

### Authentication

The SDK finds credentials on its own, in this order:

1. The current Vercel request's OpenID Connect token, or `VERCEL_OIDC_TOKEN`
   
2. An access token in `VERCEL_TOKEN`, together with `VERCEL_PROJECT_ID` and `VERCEL_TEAM_ID`
   

The Vercel Python runtime supplies request credentials to the SDK. For local scripts, Step 1 downloads a development token and loads it from `.env.local`.

## Prerequisites

Before you begin, make sure you have:

- A [Vercel account](https://vercel.com/signup).
  
- Python 3.12 or a newer version supported by the [Vercel Python runtime](https://vercel.com/docs/functions/runtimes/python).
  
- Working knowledge of `async`/`await` and [FastAPI](https://fastapi.tiangolo.com/).
  

For local development, you also need [uv](https://docs.astral.sh/uv/), Node.js 24+, and the [Vercel CLI](https://vercel.com/docs/cli).

## Step 1: Set up the project

Create a project, add the SDK, and link it to Vercel so it can issue credentials:

```bash
uv init code-runner --python 3.12
cd code-runner
uv add vercel python-dotenv
vercel link
vercel env pull
```

`vercel env pull` _writes a short-lived OIDC token to_ `.env.local`_._ `python-dotenv` _loads it into your scripts._

The development token expires after 12 hours. If a previously working script reports an authentication error, refresh the token with `vercel env pull` and restart the script so it reloads `.env.local`.

## Step 2: Run your first sandbox

Create a sandbox, run Python inside it, and print the operating-system information returned by the process. Save this as `first_sandbox.py`:

```python
import asyncio

from dotenv import load_dotenv
from vercel import sandbox

load_dotenv(".env.local")


async def main() -> None:
    async with sandbox.create_sandbox() as box:
        result = await box.run_process(
            "python",
            ["-c", "import platform; print(platform.platform())"],
            capture_output=True,
            check=True,
        )
        print(result.stdout)


asyncio.run(main())
```

_Run it with_ `uv run first_sandbox.py`_._

The script prints the sandbox's Linux platform string. Its context manager and process options control cleanup, output, and error handling:

- `async with` owns the lifecycle. When the block exits, the SDK stops and destroys the sandbox, even if your code raises.
  
- `run_process()` takes the command and its arguments separately, like `subprocess.run()`. `capture_output=True` stores stdout and stderr on the result instead of streaming them to your terminal.
  
- `check=True` raises `subprocess.CalledProcessError` on a nonzero exit, the same exception `subprocess` uses.
  

With no `image`, the sandbox boots from `vercel/sandbox/universal:latest`, which includes Python 3.14, Node.js 24, uv, and git.

## Step 3: Run someone else's code against tests

The supplied `slugify()` implementation replaces each space with a hyphen. Use two tests to show how the runner reports a passing case and a failure.

Save this as `run_tests.py`:

```python
import asyncio
from datetime import timedelta

from dotenv import load_dotenv
from vercel import sandbox
from vercel.sandbox import NetworkPolicy

load_dotenv(".env.local")

SOLUTION = '''
def slugify(title: str) -> str:
    return title.lower().replace(" ", "-")
'''

TESTS = '''
from solution import slugify

def test_basic():
    assert slugify("Hello World") == "hello-world"

def test_extra_spaces():
    assert slugify("  Hello   World ") == "hello-world"
'''


async def main() -> None:
    async with sandbox.create_sandbox(
        execution_time_limit=timedelta(minutes=2),
    ) as box:
        await box.run_process(
            "python", ["-m", "pip", "install", "--user", "-q", "pytest"],
            check=True,
        )
        await box.update_network_policy(NetworkPolicy.deny_all())

        await box.fs.mkdir("exercise")
        async with box.fs.batch(cwd="exercise") as batch:
            batch.write_text("solution.py", SOLUTION)
            batch.write_text("test_solution.py", TESTS)

        result = await box.run_process(
            "python", ["-m", "pytest", "-q"],
            cwd="exercise",
            capture_output=True,
            kill_after=60,
        )
        print(f"Exit code: {result.returncode}")
        print(result.stdout)


asyncio.run(main())
```

Run `uv run run_tests.py`. The basic case passes, but `test_extra_spaces` fails because repeated spaces produce repeated hyphens.

- `box.fs.batch()` uploads both staged files when its block exits. Use `box.fs.write_text()` for a single file.
  
- Omitting `check=True` lets the runner return a failing test result without raising `CalledProcessError`.
  
- `kill_after=60` sends `SIGKILL` if pytest runs for 60 seconds. The separate session limit also bounds setup and any remaining processes.
  

This fixed example captures all output. Step 6 adds a bounded output buffer for requests that contain arbitrary submissions.

## Step 4: Lock the sandbox down

The runner uses two time limits. `kill_after` limits the pytest process, while `execution_time_limit` bounds the whole running session, including setup. Choose a session limit that leaves enough time to install dependencies and collect the result.

Network access is separate from process isolation. Step 3 installs pytest while the network is available, then switches to `NetworkPolicy.deny_all()` before it writes or runs the submitted code.

If a workload needs an external API, allow only its required destination. For example:

```python
from vercel.sandbox import NetworkPolicy

policy = NetworkPolicy.custom(allow={"api.github.com": ()})
```

Pass this policy as `network_policy` when creating the sandbox. This example permits the destination without adding credentials. The [API access example](#let-the-sandbox-call-an-api-without-seeing-the-key) below adds a credential outside the sandbox.

## Step 5: Start faster with a snapshot

Installing pytest for every submission adds setup time and requires network access. Install it once in a trusted sandbox and save a snapshot, then create test runners from that prepared filesystem. Save the setup script as `build_snapshot.py`:

```python
import asyncio

from dotenv import load_dotenv
from vercel import sandbox

load_dotenv(".env.local")


async def main() -> None:
    box = await sandbox.create_sandbox(persistent=False)
    try:
        await box.run_process(
            "python", ["-m", "pip", "install", "--user", "-q", "pytest"], check=True
        )
        snapshot = await box.snapshot(expiration=0)  # 0 means it never expires
    finally:
        await box.destroy()  # the snapshot outlives the sandbox

    print(f"PYTEST_SNAPSHOT_ID={snapshot.id}")


asyncio.run(main())
```

Run `uv run build_snapshot.py` and keep the printed `PYTEST_SNAPSHOT_ID`. Taking the snapshot stops the session. The snapshot remains stored until you delete it because this example sets `expiration=0`.

The script uses `await` to create the sandbox and calls `destroy()` explicitly. This preserves the snapshot. Exiting `async with sandbox.create_sandbox()` also removes orphaned snapshots, which would remove the prepared snapshot before another sandbox uses it.

Use the saved ID as `snapshot_id` in the following excerpt inside an async function. The new runner starts with pytest installed and network access denied:

```python
from datetime import timedelta

from vercel.sandbox import NetworkPolicy, SnapshotSource

async with sandbox.create_sandbox(
    source=SnapshotSource(snapshot_id=snapshot_id),
    network_policy=NetworkPolicy.deny_all(),
    execution_time_limit=timedelta(minutes=2),
) as box:
    ...  # pytest is already installed
```

For a workspace that needs to retain edits between runs, use a persistent sandbox instead of creating a fresh runner each time. The [workspace example](#give-each-user-a-workspace-that-persists) below shows how to retrieve it by name.

## Step 6: Serve it from FastAPI and deploy

Save the endpoint in `main.py` at the project root. It requires a shared bearer token for this demonstration, limits each submitted file to 100,000 characters, and retains at most 20,000 characters of combined output.

The client supplies both the solution and tests, so `passed` reports the process exit status. Submitted code can alter files or exit deliberately; trusted grading needs an independent evaluation design.

```python
import io
import os
import secrets
import subprocess
from datetime import timedelta

import pydantic
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from vercel import sandbox
from vercel.sandbox import NetworkPolicy, SnapshotSource

app = FastAPI()
SNAPSHOT_ID = os.environ["PYTEST_SNAPSHOT_ID"]
RUNNER_TOKEN = os.environ["RUNNER_TOKEN"]
if not RUNNER_TOKEN:
    raise RuntimeError("RUNNER_TOKEN must not be empty")
bearer = HTTPBearer()


def require_runner_token(
    credentials: HTTPAuthorizationCredentials = Depends(bearer),
) -> None:
    if not secrets.compare_digest(
        credentials.credentials.encode(), RUNNER_TOKEN.encode()
    ):
        raise HTTPException(status_code=401, detail="Invalid runner token")


class CappedOutput(io.StringIO):
    def __init__(self, limit: int = 20_000) -> None:
        super().__init__()
        self.limit = limit
        self.truncated = False

    def write(self, value: str) -> int:
        remaining = max(0, self.limit - self.tell())
        super().write(value[:remaining])
        self.truncated |= len(value) > remaining
        return len(value)


class Submission(pydantic.BaseModel):
    solution: str = pydantic.Field(max_length=100_000)
    tests: str = pydantic.Field(max_length=100_000)


class Result(pydantic.BaseModel):
    passed: bool
    output: str
    truncated: bool


@app.post("/api/check", dependencies=[Depends(require_runner_token)])
async def check(submission: Submission) -> Result:
    output = CappedOutput()
    async with sandbox.create_sandbox(
        source=SnapshotSource(snapshot_id=SNAPSHOT_ID),
        network_policy=NetworkPolicy.deny_all(),
        execution_time_limit=timedelta(minutes=2),
    ) as box:
        await box.fs.mkdir("exercise")
        async with box.fs.batch(cwd="exercise") as batch:
            batch.write_text("solution.py", submission.solution)
            batch.write_text("test_solution.py", submission.tests)

        result = await box.run_process(
            "python", ["-m", "pytest", "-q"],
            cwd="exercise",
            stdout=output,
            stderr=subprocess.STDOUT,
            kill_after=60,
        )

    return Result(
        passed=result.returncode == 0,
        output=output.getvalue(),
        truncated=output.truncated,
    )
```

The context manager destroys the sandbox after the run. The capped text buffer limits output retained by this app; it does not prevent the sandbox from generating logs or eliminate the cost of receiving them. Add per-user authorization and rate limits before offering the runner to multiple users.

Add FastAPI, then save the snapshot ID and a randomly generated `RUNNER_TOKEN` in the project's Development, Preview, and Production environments. Enter the values at the CLI prompts:

```bash
uv add fastapi
vercel env add PYTEST_SNAPSHOT_ID
vercel env add RUNNER_TOKEN development
vercel env add RUNNER_TOKEN preview
vercel env add RUNNER_TOKEN production
vercel pull --environment=development
vercel dev
```

Use a different runner token for production. Keep it on trusted clients and servers, rather than embedding it in browser JavaScript. `vercel dev` uses the linked project's development environment.

In another terminal, set `RUNNER_TOKEN` to the development value and send a request:

```bash
curl -X POST http://localhost:3000/api/check \
  -H "Authorization: Bearer $RUNNER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"solution": "def add(a, b):\n    return a + b\n", "tests": "from solution import add\n\ndef test_add():\n    assert add(2, 3) == 5\n"}'
```

Expect `passed: true` and `truncated: false`, with the pytest result in `output`. Requests without a valid token are rejected before they create a sandbox.

Confirm the Vercel Function's [maximum duration](https://vercel.com/docs/functions/configuring-functions/duration) leaves time for sandbox startup, the 60-second test run, and cleanup. Then commit `uv.lock` and deploy:

```bash
vercel deploy        # preview
vercel deploy --prod # production
```

For Sandbox access, the Vercel Python runtime passes each request's OIDC token to the SDK automatically. This is separate from `RUNNER_TOKEN`, which controls who may call your code-runner endpoint.

To see what your app has running, `sandbox.query_sandboxes()` lists sandboxes with their status. Pass `tags={...}` when you create them, and you can filter by tag later.

## Use cases

| Use case                           | Building blocks                                  | Example      |
| ---------------------------------- | ------------------------------------------------ | ------------ |
| Code checking and grading          | `fs.batch`, `run_process`, `deny_all`, snapshots | Steps 3 to 6 |
| Running code an AI model wrote     | An AI SDK tool wrapping `create_sandbox`         | Below        |
| Live previews of apps and branches | `GitSource`, `ports`, `create_process`, `routes` | Below        |
| Per-user development workspaces    | `get_or_create_sandbox`, `persistent=True`       | Below        |
| Calling APIs without exposing keys | `NetworkPolicy.custom` with a header transform   | Below        |
| Long jobs with live output         | `create_process` and its `stdout` stream         | Below        |

### Run code an AI model wrote

Install the [AI SDK for Python](https://ai-python.dev/docs) with `uv add ai`.

This tool reuses `CappedOutput` in `main.py`:

```python
import ai


@ai.tool
async def run_python(code: str) -> str:
    """Run Python in an isolated sandbox and return bounded process output."""
    if len(code) > 100_000:
        return "Code exceeds the 100,000-character limit."

    output = CappedOutput()
    async with sandbox.create_sandbox(
        network_policy=NetworkPolicy.deny_all(),
        execution_time_limit=timedelta(minutes=2),
    ) as box:
        await box.fs.write_text("main.py", code)
        result = await box.run_process(
            "python", ["main.py"],
            stdout=output,
            stderr=subprocess.STDOUT,
            kill_after=30,
        )
    suffix = "\n[Output truncated]" if output.truncated else ""
    return f"Exit code: {result.returncode}\n{output.getvalue()}{suffix}"
```

Pass the tool to `ai.Agent(tools=[run_python])`. Python exceptions printed by the sandbox are returned with the process output. Authentication, provisioning, and transport errors still need error handling in the application that runs the agent.

### Preview an app from a Git branch

Clone a repo into the sandbox, start its dev server in the background, and hand someone the URL:

```python
from datetime import timedelta

from vercel.sandbox import GitSource

box = await sandbox.create_sandbox(
    source=GitSource(url="https://github.com/vercel/sandbox-example-next.git", depth=1),
    ports=[3000],
    execution_time_limit=timedelta(minutes=30),
)
repo = "sandbox-example-next"  # cloned into a folder named after the repo

await box.run_process("npm", ["install"], cwd=repo, check=True)
server = await box.create_process("npm", ["run", "dev"], cwd=repo)

preview_url = next(route.url for route in box.routes if route.port == 3000)
```

`GitSource` clones into a folder named after the repository, so the process uses that folder as `cwd`. `create_process()` returns a process handle before the command exits; inspect its output and confirm the URL responds before sharing the preview.

Run this excerpt in an async function. Clean up with `await box.destroy(delete_orphan_snapshots=True)` when the preview is no longer needed, or use `stop()` to retain it for reuse.

### Give each user a workspace that persists

Choose `user_id` from your server's authenticated user record, then derive a stable sandbox name. The name identifies the workspace; your app must still enforce who may use it. Run the following inside an async function:

```python
from vercel.sandbox import SnapshotRetention

box, created = await sandbox.get_or_create_sandbox(
    name=f"workspace-{user_id}",
    persistent=True,
    snapshot_retention=SnapshotRetention(count=1),
)

try:
    if created:
        await box.fs.write_text("notes.md", "# Workspace notes\n")
    notes = await box.fs.read_text("notes.md")
finally:
    await box.stop()
```

Stopping the session saves its filesystem. Retrieving the named sandbox resumes it by default. Coordinate concurrent requests for the same workspace so one request does not stop another request's work, and make any required initialization safe to retry after a failure.

`SnapshotRetention(count=1)` limits the number of retained snapshots. Storage can still grow as workspace files grow.

For data shared between sandboxes, mount a [Vercel Drive](https://vercel.com/kb/guide/vercel-drives).

### Let the sandbox call an API without seeing the key

The firewall can inject an authorization header after a request leaves the sandbox. Keep the token in your application environment and give it only the GitHub permissions the task needs. Run this excerpt inside an async function:

```python
import os

from vercel.sandbox import NetworkPolicy, NetworkPolicyRule, NetworkPolicyTransform

policy = NetworkPolicy.custom(
    allow={
        "api.github.com": [
            NetworkPolicyRule(
                transform=[
                    NetworkPolicyTransform(
                        headers={
                            "Host": "api.github.com",
                            "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
                        }
                    )
                ]
            )
        ]
    }
)

async with sandbox.create_sandbox(network_policy=policy) as box:
    result = await box.run_process(
        "curl", ["--fail", "https://api.github.com/user"],
        capture_output=True,
        check=True,
    )
```

The sandbox can reach the allowed API and use the injected token's permissions even though it cannot read the token itself. Domain access does not restrict the process to a particular API operation. Use a scoped token or an application proxy when the workload must be limited to specific actions.

### Stream output from long jobs

Use `create_process()` to consume output while a command runs. Merge stderr into stdout when both streams belong in the same log display:

```python
import subprocess

process = await box.create_process(
    "python", ["-m", "pytest", "-v"],
    cwd="exercise",
    stderr=subprocess.STDOUT,
    kill_after=60,
)

assert process.stdout is not None
async for line in process.stdout:
    print(line, end="")

exit_code = await process.wait()
```

This excerpt assumes the `exercise` folder and its tests already exist. It suits known test output. For arbitrary code, use the bounded writer from Step 6 because line-based reading can buffer a long line before yielding it.

### Make it durable

For a process that runs code, tests it, and waits for review, use the [Python Workflow SDK](https://workflow-sdk.dev/docs/getting-started/python) to coordinate the stages. Put external operations in workflow steps and make their side effects safe to retry. Retrying a step can create another sandbox or repeat a command, so persist the identifiers or results needed to recover safely.

The [notebook app](https://github.com/vercel-labs/notebook-factory) combines Sandbox, the AI SDK for Python, and Workflows.

### When to use something else

- Run trusted application code directly in a Vercel Function when it does not need a separate execution environment.
  
- Use [Vercel Daemons](https://vercel.com/changelog/vercel-daemons-is-now-in-public-beta) for continuously running workers, or [Vercel Queues](https://vercel.com/docs/queues) for message-triggered work.
  
- Use `sandbox connect` when you want an interactive terminal from the command line.
  

## Next steps

- Read the full [Python SDK reference](https://vercel.com/docs/sandbox/python-sdk-reference) for sessions, forks, drives, regions, and error types
  
- Learn how [duration and persistence work](https://vercel.com/kb/guide/vercel-sandbox-duration-and-persistence), and what each costs
  
- Speed up startup further with [snapshots](https://vercel.com/kb/guide/how-to-use-snapshots-for-faster-sandbox-startup)
  
- Share storage across sandboxes with [Vercel Drives](https://vercel.com/kb/guide/vercel-drives)

## Related Documentation

- [Vercel Sandbox](/docs/vercel-sandbox)

## More Vercel Sandbox guides

- [Vercel Sandbox vs E2B](/kb/guide/vercel-sandbox-vs-e2b): A detailed guide to Vercel Sandbox vs E2B: compute lifecycle, security controls, code execution, pricing, and when to choose each sandbox for your AI agent workloads.
- [Vercel Sandbox vs CodeSandbox](/kb/guide/vercel-sandbox-vs-codesandbox): A detailed guide to Vercel Sandbox vs CodeSandbox: compute isolation, credential brokering, Active CPU billing, Docker support, persistent environments, and when to choose each product for your AI workloads.
- [How to perform large-scale data analysis with Vercel Sandbox and MCP](/kb/guide/large-scale-data-analysis-vercel-sandbox-mcp): Use Vercel Sandbox and an MCP server built with \`mcp-handler\` to let AI agents run DuckDB SQL and Python on large datasets without loading data into the model's context.