Skip to content
Guides · Who it's for

Are Sandboxer Sandboxes a Fit for Data Scientists?

Updated October 27, 2026 .md

Short answer: Yes, for CPU-only work that fits in RAM. Pick the size by peak memory, not file size: loading every column of a 473 MB Parquet file was killed on small (4 GB) and fit on default (8 GB). Run JupyterLab as a service on a private URL, and stop the sandbox between sessions to keep kernels warm.

The jobs, and what to use for each#

A data scientist asks six things of a remote machine. Each row gives the short answer and the guide with a full run on prod. If you are building a product that runs model-written code for your users, see Sandboxer for AI app builders instead; for agent evals and benchmarks, see Sandboxer for eval and research teams.

Job Short answer Sandboxer feature Worked guide
Notebooks in the browser JupyterLab as an enabled systemd service, hosted on a private URL sbx host <id> 8888 JupyterLab on an HTTPS URL
Share a dashboard Streamlit on port 8501, same private URL model sbx host <id> 8501 Run a Streamlit app
Track experiments MLflow server with SQLite, logged to from your laptop sbx host --localhost MLflow tracking server
Get data in and out Download public data inside the VM; sbx scp for your own files; S3 API if you want buckets sbx scp, files API, MinIO Move files in and out, MinIO
Keep a loaded kernel between sessions sbx stop --wait at night, sbx resume in the morning; check memoryRestored Memory snapshots Pause and resume with memory, Detect a cold boot
A long training or ETL run Run it detached or as a service and write checkpoints to disk sbx exec --detach, --ttl Run a long job

There is no GPU. Sandboxes are CPU-only: small 2 vCPU/4 GB, default 4/8, large 8/16, xlarge 16/32 (needs the $100 plan). Train or serve models that need a GPU somewhere else.

Which size for pandas and polars#

We loaded one public file on small and default on 2026-10-27: the NYC Taxi and Limousine Commission's High Volume For-Hire Vehicle trip records for January 2024, fhvhv_tripdata_2024-01.parquet. It is 472,757,547 bytes on disk and 19,663,930 rows. Versions: pandas 3.1.0, pyarrow 26.0.0, polars 2.1.0, Python 3.12.

Each case ran in a fresh Python process. Peak RSS is that process's ru_maxrss; wall time includes the import.

Case small (2 vCPU, 4 GB) default (4 vCPU, 8 GB)
polars lazy scan, group-by on 3 columns 0.5 s, 0.27 GiB 0.4 s, 0.41 GiB
pandas, read 3 columns, group-by 1.4 s, 1.61 GiB 1.3 s, 1.64 GiB
polars, read every column Killed (signal 9) at 3.62 GiB 1.6 s, 4.67 GiB
pandas, read every column Killed (signal 9) at 3.62 GiB 2.6 s, 7.17 GiB

The file is under 0.5 GB, but every column in pandas took 3.76 GiB as a DataFrame and peaked at 7.17 GiB during the read. That is near the top of default. Rules that follow from these numbers:

  • Read only the columns you need. Three columns fit on small with room to spare, in both libraries.
  • Use polars' lazy API for aggregates. scan_parquet(...).group_by(...).collect() read the whole file's worth of rows with a peak of 0.27 GiB on small and 0.41 GiB on default.
  • Budget about 16 times the Parquet size for a full pandas load of a file like this one, then pick the size above it. Compressed Parquet with string columns expands a lot in memory.
  • An out-of-memory kill doesn't take the sandbox down. The process got signal 9 (a shell shows exit 137), and the next command in the same sandbox ran normally. See exit code 137.

The benchmark script, uploaded with sbx scp bench.py <id>:/home/user/bench.py:

# bench.py <case> : run one load in a child process, print wall time, peak RSS and exit code
import resource, subprocess, sys, time
F = "/home/user/data/fhvhv_tripdata_2024-01.parquet"
COLS = ["hvfhs_license_num", "trip_miles", "base_passenger_fare"]
CASES = {
  "pandas-all":  f"import pandas as pd; df = pd.read_parquet('{F}'); print(len(df), 'rows', round(df.memory_usage(deep=True).sum()/2**30, 2), 'GiB in memory')",
  "pandas-3col": f"import pandas as pd; df = pd.read_parquet('{F}', columns={COLS}); print(df.groupby('hvfhs_license_num').agg(trips=('trip_miles','size'), miles=('trip_miles','mean'), fare=('base_passenger_fare','mean')).round(2))",
  "polars-all":  f"import polars as pl; df = pl.read_parquet('{F}'); print(df.height, 'rows', round(df.estimated_size('gb'), 2), 'GB estimated')",
  "polars-lazy": f"import polars as pl; print(pl.scan_parquet('{F}').group_by('hvfhs_license_num').agg(pl.len().alias('trips'), pl.col('trip_miles').mean().round(2), pl.col('base_passenger_fare').mean().round(2)).sort('hvfhs_license_num').collect())",
}
c = sys.argv[1]
t = time.time()
p = subprocess.run([sys.executable, "-c", CASES[c]], capture_output=True, text=True)
secs = time.time() - t
peak = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss / 2**20
print(p.stdout.strip()[-600:])
print(f"## {c}: exit={p.returncode} wall={secs:.1f}s peakRSS={peak:.2f}GiB")
for c in polars-lazy pandas-3col polars-all pandas-all; do sbx exec $id --timeout 600 "~/ds/bin/python bench.py $c"; done

Output on default (the small run printed the same group-by table and exit=-9 for the two full reads):

┌───────────────────┬──────────┬────────────┬─────────────────────┐
│ hvfhs_license_num ┆ trips    ┆ trip_miles ┆ base_passenger_fare │
╞═══════════════════╪══════════╪════════════╪═════════════════════╡
│ HV0003            ┆ 14432755 ┆ 4.9        ┆ 24.34               │
│ HV0005            ┆ 5231175  ┆ 4.67       ┆ 22.91               │
└───────────────────┴──────────┴────────────┴─────────────────────┘
## polars-lazy: exit=0 wall=0.4s peakRSS=0.41GiB
…
## pandas-3col: exit=0 wall=1.3s peakRSS=1.64GiB
19663930 rows 2.59 GB estimated
## polars-all: exit=0 wall=1.6s peakRSS=4.67GiB
19663930 rows 3.76 GiB in memory
## pandas-all: exit=0 wall=2.6s peakRSS=7.17GiB

Our single-thread speed was about the same on every size in our CPU measurements. For data work, a bigger size buys RAM and cores for parallel code, not faster single-threaded pandas.

One session end to end#

This ran on prod on 2026-10-27 in one small sandbox. Timings are single runs from our client, so they include CLI and network time.

1. Create, install, download#

sbx --json new --name ds-1 --type small --ttl 3600 | jq -r 'select(.event=="ready") | .id'
id=sb_6d9mw80o33

sbx exec $id --timeout 600 "uv venv --seed ~/ds --python 3.12 -q && VIRTUAL_ENV=~/ds uv pip install -q pandas pyarrow polars && \
  ~/ds/bin/python -c 'import pandas, pyarrow, polars; print(pandas.__version__, pyarrow.__version__, polars.__version__)' && mkdir -p ~/data && \
  curl -s -o ~/data/fhvhv_tripdata_2024-01.parquet -w 'download %{size_download} bytes %{time_total}s\n' \
  https://d37ci6vzurychx.cloudfront.net/trip-data/fhvhv_tripdata_2024-01.parquet"
3.1.0 26.0.0 2.1.0
download 472757547 bytes 5.193320s

The create took 3.19 s to ready. pandas, pyarrow and polars installed in 4.9 s with uv. The 473 MB file downloaded inside the VM in 5.2 s on small (8.6 s on default in a parallel run). Pull public data straight into the sandbox; don't route it through your laptop.

2. JupyterLab on a private URL#

We installed JupyterLab 4.6.4 into the same venv (VIRTUAL_ENV=~/ds uv pip install -q jupyterlab) and ran it as an enabled systemd service on 0.0.0.0:8888 with its own token off, exactly as in the JupyterLab guide. Then:

sbx host $id 8888 --title JupyterLab
curl -s -o /dev/null -w "%{http_code}\n" https://<host>/lab           # no token
curl -s -o /dev/null -w "%{http_code}\n" "https://<host>/?_token=…"   # with token
401
302

The hosted URL is private by default, so the proxy's token guards the notebook. Treat the full URL like a password.

3. Load data into a kernel#

We started a kernel through the Jupyter REST API and ran two cells over its websocket, using the jlab-run.mjs script from the JupyterLab guide:

import time, os, pandas as pd
t = time.time()
df = pd.read_parquet('/home/user/data/fhvhv_tripdata_2024-01.parquet',
                     columns=['hvfhs_license_num', 'pickup_datetime', 'trip_miles', 'base_passenger_fare'])
print(f'loaded {len(df):,} rows in {time.time()-t:.1f}s, pid {os.getpid()}')
t = time.time()
r = df.groupby(df.pickup_datetime.dt.hour).base_passenger_fare.mean().round(2)
print(r.idxmax(), r.max(), f'{time.time()-t:.2f}s')
loaded 19,663,930 rows in 0.4s, pid 871
5 28.06 0.33s

The highest average base fare in January 2024 was for trips picked up in the 5 a.m. hour: $28.06.

4. Stop, resume, and the kernel is still warm#

sbx --json stop $id --wait     # 2.16 s
sbx --json resume $id | jq -c 'select(.event=="ready") | .sandbox | {state, memoryRestored, resumeReason}'
{"state":"ready","memoryRestored":true,"resumeReason":null}

The resume took 1.35 s. The same kernel answered on the next call:

19,663,930 rows still in memory, pid 871
5 28.06

Same PID, same df, same r, nothing reloaded. That is the reason to stop a sandbox instead of deleting it at the end of a day. If memoryRestored is false, the sandbox booted from disk: the data file and notebooks are still there, the kernel is gone, and systemd restarts JupyterLab. Rerun your load cell; from local disk it took 0.4 s here.

5. Copy results out and delete#

sbx scp subset.py $id:/home/user/subset.py           # 2.5 s
sbx exec $id "mkdir -p ~/out && ~/ds/bin/python subset.py"   # 0.86 s, polars sink_parquet
sbx scp $id:/home/user/out/hv0005.parquet out/       # 58,815,320 bytes, 27.6 s
sbx delete $id --yes

subset.py streams one company's trips into a smaller Parquet file with polars' sink_parquet. The 59 MB copy back took 27.6 s, about 2 MB/s over our own connection; your link sets that number. After the delete, sbx --json info $id returned 404 not_found and the hosted URL returned 404.

Moving data in and out#

Data Method Limits and notes
Public datasets (Parquet, CSV, Hugging Face, Kaggle) curl, wget or the library's own downloader, inside the sandbox Sandboxes reach the internet. 473 MB took 5 to 9 s in our runs
Your own files and folders, either way sbx scp, or rsync over SSH Any size; speed is your connection's
Small files from code SDK or MCP files API Read and write up to 5 MiB per file; the SDK downloads up to 50 MiB
Buckets with the S3 API MinIO in the sandbox, or the AWS CLI against your own bucket Pass your cloud keys as secrets, never in a notebook. See MinIO
Files from a stopped sandbox Read from its snapshot without resuming See Move files in and out

What it costs#

Billing is per second while a sandbox runs. A stopped sandbox costs no machine time. From pricing.ts: small is $0.018/hour, default $0.036/hour, large $0.072/hour.

Pattern Arithmetic Cost
The whole session above on small (about 150 s running) 150 / 3,600 h × $0.018 About $0.00075
A default sandbox running a load like ours for one minute 1 / 60 h × $0.036 $0.0006
An 8-hour working day on default, stopped overnight 8 × $0.036 $0.288
22 such days a month 22 × $0.288 $6.34
The same sandbox left running all month 730 × $0.036 $26.28

Stopping overnight is the difference between $6.34 and $26.28 a month, and with a memory resume you lose nothing. Idle auto-pause does the stop for you when you forget. Storage isn't billed, but a stopped sandbox's disk and memory count toward your plan's included storage (billing): 10 GB on the trial, 50 GB on the $20 plan.

Limits to plan around#

  • No GPU. Everything is CPU. Deep learning training, large-model inference and CUDA code belong elsewhere.
  • RAM is the ceiling. xlarge tops out at 32 GB and needs the $100 plan; the trial allows small and default only, with a TTL of up to 8 hours. A dataset that doesn't fit needs column selection, polars' lazy and streaming APIs, or a different machine.
  • Moving to a smaller size isn't supported yet. Create a new, smaller sandbox and copy what you need into it.
  • A resume can lose memory. A resume on a machine with a different CPU type boots from disk: files kept, processes and kernels lost, memoryRestored: false with resumeReason: "cpu_mismatch". sbx resume --require-memory waits for memory instead. Save intermediate results to Parquet so a cold boot costs seconds, not a long recompute.
  • The token URL is the login. With JupyterLab's own token off, anyone with the full hosted URL can run code in the sandbox. Never host Jupyter with --public. Switching a port between public and private mints a new token.
  • Clocks after a resume. The wall clock is corrected; elapsed-time clocks may not be. Don't time a benchmark across a stop.

FAQ#

Which size should I start with for pandas?#

Start on small if you load only the columns you need, and on default if you load whole wide tables. In our test, every column of a 473 MB Parquet file peaked at 7.17 GiB in pandas: killed on small, fit on default.

Does my notebook kernel survive when the sandbox stops?#

Yes, when the resume restores memory. Ours kept the same PID and a 19.7-million-row DataFrame across sbx stop --wait and sbx resume. If memoryRestored is false, files and notebooks are kept but you rerun your cells.

Can I use a GPU?#

No. Sandboxes are CPU-only on every size and plan.

How do I share a Streamlit dashboard or MLflow UI with a colleague?#

Host the port with sbx host and send them the private URL with its token. The Streamlit and MLflow guides show the exact service files and the flags MLflow needs behind the proxy.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide