# Are Sandboxer Sandboxes a Fit for Data Scientists?

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

Source: https://sandboxer.com/guides/sandboxes-for-data-scientists. Updated 2026-10-27.

**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](/guides/sandboxes-for-ai-app-builders) instead; for agent evals and benchmarks, see [Sandboxer for eval and research teams](/guides/sandboxes-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](/guides/run-jupyterlab-on-an-https-url) |
| Share a dashboard | Streamlit on port 8501, same private URL model | `sbx host <id> 8501` | [Run a Streamlit app](/guides/run-a-streamlit-app-in-a-sandbox) |
| Track experiments | MLflow server with SQLite, logged to from your laptop | `sbx host --localhost` | [MLflow tracking server](/guides/run-mlflow-tracking-server-in-a-sandbox) |
| 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](/guides/move-files-in-and-out-of-a-sandbox), [MinIO](/guides/run-minio-s3-storage-in-a-sandbox) |
| 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](/guides/pause-and-resume-a-sandbox-with-memory), [Detect a cold boot](/guides/detect-a-cold-boot-after-resume) |
| 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](/guides/run-a-long-job-in-a-cloud-sandbox) |

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](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page), `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](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox).

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

```python
# 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")
```

```bash
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):

```text
┌───────────────────┬──────────┬────────────┬─────────────────────┐
│ 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](/guides/cpu-performance-by-sandbox-size). 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

```bash
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"
```

```text
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](/guides/run-jupyterlab-on-an-https-url). Then:

```bash
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
```

```text
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:

```python
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()}')
```

```python
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')
```

```text
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

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

```text
{"state":"ready","memoryRestored":true,"resumeReason":null}
```

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

```text
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

```bash
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](/guides/run-minio-s3-storage-in-a-sandbox) |
| Files from a stopped sandbox | Read from its snapshot without resuming | See [Move files in and out](/guides/move-files-in-and-out-of-a-sandbox) |

## 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](/guides/idle-auto-pause-and-wake-on-use) 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](/docs/billing#storage)): 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](/guides/run-a-streamlit-app-in-a-sandbox) and [MLflow](/guides/run-mlflow-tracking-server-in-a-sandbox) guides show the exact service files and the flags MLflow needs behind the proxy.

## Related

- [Run JupyterLab on an HTTPS URL](/guides/run-jupyterlab-on-an-https-url)
- [Build a data-analysis agent with a sandbox](/guides/build-a-data-analysis-agent-with-a-sandbox)
- [Billing and storage](/docs/billing)
- [Hosting a port](/docs/hosting)
