---
title: Python filesystem
icon: file-binary
description: Use Python's open(), os.listdir, pathlib, PIL, pandas, directly against any Mirage mount.
---

Python code inside `ws.execute('python3 ...')` can `open()`, `os.listdir()`, `pathlib.Path()` etc. against any registered Mirage mount. Mirage mounts a filesystem of its own inside the interpreter, below the syscall boundary, so those calls reach Mirage's mount layer (RAM, S3, Linear, GDocs, Slack, anything you've registered).

```python
# inside python3
import json
team = json.load(open('/linear/teams/eng/team.json'))   # reads through Linear's API
open('/ram/notes.txt', 'w').write('hello')              # writes flush back to RAMResource
```

## What works

<Tabs>
  <Tab title="Read a file">
    ```python
    open('/s3/data.csv').read()
    open('/linear/teams/eng/team.json').read()
    open('/gdocs/owned/MyDoc.gdoc.json').read()
    ```
    Every mounted prefix is collected before the run, so reads are sync and cost no round trip.
  </Tab>
  <Tab title="Write a file">
    ```python
    open('/ram/out.txt', 'w').write('hello')
    open('/s3/icon.png', 'wb').write(png_bytes)
    ```
    `close()` records the write; when the script finishes, the bytes go back through the resource's `write` op, or its `append` op when the handle only extended the file.
  </Tab>
  <Tab title="List a directory">
    ```python
    import os
    for entry in os.listdir('/linear/teams/'):
        print(entry)

    # Synthesized paths work too, Gmail materializes date dirs on access:
    for msg_dir in os.listdir('/gmail/INBOX/2026-05-03/'):
        print(msg_dir)
    ```
    A synthesized path is materialized when the prefix is collected, before the run.
  </Tab>
  <Tab title="pathlib & glob">
    ```python
    from pathlib import Path
    Path('/gdocs/owned/MyDoc.gdoc.json').read_text()

    import glob
    for f in glob.glob('/ram/*.txt'):
        print(f)
    ```
  </Tab>
  <Tab title="Native libs">
    ```python
    from PIL import Image
    Image.new('RGB', (4, 4), color='red').save('/ram/icon.png')

    import json
    json.dump({'k': 'v'}, open('/ram/cfg.json', 'w'))

    import numpy as np
    np.save('/ram/arr.npy', np.arange(10))
    ```
    Anything that goes through Python's `open()` works (PIL, numpy, pandas, json, pickle).
  </Tab>
  <Tab title="Symlinks & metadata">
    ```python
    import os

    os.symlink('report.json', '/ram/latest.json')
    print(os.readlink('/ram/latest.json'), os.path.islink('/ram/latest.json'))

    os.utime('/ram/report.json', (1767225600, 1767225600))
    os.chmod('/ram/report.json', 0o600)
    ```
    A link is namespace state, so no backend has to be able to store one:
    the op reaches the name plane and a link lands on an S3 or Notion mount
    the same way it lands on a RAM one. Links the shell made are collected
    with the prefix, as links rather than as copies of their targets, so a
    cyclic one is safe to walk past. A `chmod` or `utime` a backend cannot
    keep is stored in the namespace overlay and reported back by `stat`, and
    an `lstat` reads the link's own row rather than its target's, so a
    `follow_symlinks=False` stamp is visible right after it is written.
    `os.rename` moves a link by its own name instead of following it, and
    `os.symlink` onto a name that is taken raises `FileExistsError`, both as
    POSIX has them.
  </Tab>
  <Tab title="Cross-mount">
    ```python
    # Read from one mount, write to another, same Python code:
    import os, json

    out = []
    for t in os.listdir('/linear/teams/')[:5]:
        team = json.load(open(f'/linear/teams/{t}/team.json'))
        out.append({'name': team['name']})

    json.dump(out, open('/ram/summary.json', 'w'), indent=2)
    ```
  </Tab>
</Tabs>

## What doesn't work

| Case | Why | Workaround |
|---|---|---|
| **External edits show up mid-run**, someone else edits the GDoc while your script runs | The tree is collected before the run and served synchronously after that | Each run re-collects, so the next one sees the edit |
| **Huge mounts**, collecting a 100GB S3 bucket | Every byte is held in memory for the run | Mount a narrower prefix |
| **Concurrent writers**, two Python processes write the same path | Last `close()` wins, no conflict detection | Out of scope for v1 |
| **Browser-only resources from Node**, OPFS | OPFS isn't a thing in Node | Run in a browser-side Mirage |

## How it works (one paragraph)

Mirage registers its own Emscripten filesystem at each mount prefix, so every spelling of an operation (`open`, `os.open`, `pathlib`, a C extension's `fopen`, `shutil`'s fd-relative walk) arrives as the same callback. Emscripten's callbacks are synchronous, so nothing reaches the mount inline: before each run Mirage collects every mounted prefix into the filesystem's node table, and reads are served from there. Writes are recorded on a journal in the order the guest performed them, and replayed against the mounts after the script returns, where awaiting is free. A handle that only extended a file replays as an `append`, so an append loop ships its tail rather than the whole file each time. A failed replay lands on stderr with a nonzero exit, and stops the rest of the journal rather than applying entries whose prerequisite never landed.

```
Python open() / os.open() / C fopen()
        │
        ▼
   Mirage filesystem callbacks (sync)
        │                    │
      reads                writes
        │                    │
        ▼                    ▼
   node table          mutation journal
   (collected               │
    before the run)    script ends
                            │
                            ▼
                    replay against mounts
```

Nothing is patched inside the interpreter, and none of it needs JSPI.

## Quick start (TypeScript)

```ts
import { Workspace, RAMResource, S3Resource, MountMode } from '@struktoai/mirage-node'

const ws = new Workspace({}, { mode: MountMode.WRITE })
ws.addMount('/ram', new RAMResource(), MountMode.WRITE)
ws.addMount('/s3', new S3Resource({ bucket: 'my-bucket', region: 'us-east-1' }), MountMode.READ)

await ws.fs.writeFile('/ram/in.json', '{"hello":"world"}')

const r = await ws.execute(`python3 -c '
import json
print(json.load(open("/ram/in.json"))["hello"])
'`)
console.log(r.stdoutText)  // "world"
```

See the full demo at [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts).

## Python packages (PIL, numpy, pandas, …)

Pyodide ships CPython, but third-party packages aren't loaded until you import them. Mirage scans the code you run for `import` statements and **auto-fetches matching packages on demand**, so `from PIL import Image`, `import numpy as np`, `import pandas as pd` all just work the first time. Subsequent calls hit Pyodide's package cache.

If you need to opt out (e.g., to keep workspace startup lean):

```ts
new Workspace({}, { python: { autoLoadFromImports: false } })
```

## Resource compatibility

| Resource | Status |
|---|---|
| RAM, OPFS (browser), Disk | ✅ |
| S3, R2, GCS, OCI, Supabase | ✅ (narrow the prefix to keep the working set small) |
| Linear, GDocs, GSheets, GSlides, GDrive | ✅ |
| GitHub, Redis | ✅ |
| Gmail (including synthesized date dirs) | ✅ (materialized when the prefix is collected) |
| Slack | ⚠️ preloads channel histories; large workspaces may be slow |

## Runtime requirements

None. Reads, mounts added between runs, and writes all work on any engine, with no V8 flag and no [JSPI](https://github.com/WebAssembly/js-promise-integration). That is the point of collecting the tree before the run and replaying writes after it: a filesystem callback never has to suspend, so nothing depends on stack switching, which no shipping Node enables by default and Safari does not implement.

To run the example:

```bash
node --import tsx/esm examples/typescript/pyodide/vfs.ts
```

## Errors you might see

| Symptom | What it means |
|---|---|
| `FileNotFoundError [Errno 44]` | The path was not in the tree collected before the run. A callback cannot go looking for more, so this is a genuine absence at collection time. |
| `python3: failed to <op> <path> on mount: ...` on stderr, exit 1 | The script finished but a recorded mutation could not be applied (auth, read-only mount, network). |
| `python3: skipped N later mutation(s) after that failure` | The journal is a sequence, not a set, so the replay stops at the first failure rather than applying entries whose prerequisite never landed. |
| `OSError [Errno 5]` on opening a file you can see | The mount listed the file but would not serve its bytes, so its real content is unknown and any handle is refused rather than guessing. |
| `console.warn: mirage preload: cannot read <path>` | One entry failed while the prefix was collected, others continued. The file is marked unreadable rather than left looking absent. |

## See also

- [`python`](/typescript/python): broader `python3` builtin behavior (env, argv, stdin, exit codes)
- [`examples/typescript/pyodide/vfs.ts`](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/pyodide/vfs.ts): runnable demo across RAM, S3, GDocs, Linear
