# Reading a table

Source: https://mostlyright.md/docs/reference/cli/read/

Preview rows with `peek`, query them with SQL, use `download` for the artifacts or list the Parquet parts.

## peek

```bash
mr-data peek RUN_ID [--rows N] [--no-activity] [--json]
```

Print the columns, the types and the first rows of the preview a run wrote. The preview is an artifact of the run and arrives decoded, so no worker wakes for it.

Run it after a `--sample` run, before deciding the recipe is right. To ask the data a question, use [`query`](#query).

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `RUN_ID` | id | yes | The hosted run, by the identifier the submission printed. | none |
| `--rows` | `N` | no | How many preview rows to print. It narrows and never widens. The run fixed the preview's size when it finished. | `5` |
| `--no-activity` | none | no | Accepted, and has no effect from 0.27.1: this command tells the dataset page nothing. | off |
| `--json` | none | no | One JSON object instead of human lines. | off |

Nine flags do nothing here, because the hosted preview arrives decoded. They are `--format`, `--reader`, `--reader-options`, `--allow-redirect-host`, `--json-records-pointer`, `--json-expand`, `--json-column`, `--json-optional-column` and `--json-document-format`. The payload reports the ones you passed under `flags_without_effect` instead of failing the command.

```json title="mr-data peek RUN_ID --json"
{
  "schema_version": "mostlyright-thin-client-v4-peek.v1",
  "status": "peeked",
  "lane": "hosted",
  "run_id": "7c9e1d34-8b52-4f61-9a03-2e5d6f7a8b90",
  "dashboard_url": "https://mostlyright.md/datasets",
  "artifact_id": "5e2a9c74-1d6b-4308-b9c5-8f0a3d7e2b41",
  "row_count": 4100000,
  "columns": ["city", "date", "max_temp_c"],
  "schema": {
    "column 1": { "name": "city", "type": "string" },
    "column 2": { "name": "date", "type": "date" },
    "column 3": { "name": "max_temp_c", "type": "double" }
  },
  "sample": { "row 1": { "city": "Boston", "date": "2026-09-11", "max_temp_c": 24.4 } },
  "input_sha256": "9f3c…",
  "flags_without_effect": {},
  "note": "Showing 5 of the 50 rows this run's preview holds, out of 4100000 in the table."
}
```

Values are illustrative. `row_count` is the table's total, not the preview's. A column's `type` stays `null` when the run wrote no column profile, and the command fetches that profile only when a type is missing. `note` appears when the print is shorter than the preview holds. It appears too when the preview calls itself `truncated`. The run then wrote fewer rows than the table has, even with every preview row printed.

Errors: `THIN_NO_PREVIEW_SEALED` and `THIN_NO_MATCHING_ARTIFACT` when the run wrote no preview. Also `THIN_ARTIFACT_TOO_LARGE` and `THIN_NOT_FOUND`.

```bash
mr-data peek "$RUN_ID" --rows 20 --json | jq '.columns, .sample'
```

## query

```bash
mr-data query RUN_ID SQL [--max-rows N] [--field FIELD] [--from VALUE] [--to VALUE]
              [--partition NAME=VALUE …] [--no-session] [--no-activity] [--json]
```

Ask the table this run produced one bounded read-only question.

Studio queues the question. The command reads the answer back at most 40 times and for at most 120 seconds, whichever comes first. It never holds a long poll. A wait that ends first leaves the question running and prints its identifier. Run the same command again and it reads that question back. Studio derives the identifier from the statement, the row limit and the prune block.

### The statement

The SQL is read-only, one statement, and begins with `SELECT`, `WITH`, `EXPLAIN` or `DESCRIBE`. Address the run's table as `run_table`, whatever the recipe named it. The whole submission (statement, row limit and prune block, as canonical JSON) is at most 64 KiB.

Studio's statement gate blanks comments and quoted text, then scans the rest on word boundaries. It refuses a column literally named `system`, `copy`, `set`, `load`, `call`, `glob`, `import` or `export`. Writing that identifier in `"double quotes"` puts it outside the scan.

The client holds no SQL allow-list. The warm worker's read-only DuckDB connection, with external access off, is the boundary.

### Which parts a question opens

A table version is many Parquet parts, and a bounded read opens only so many of them. Studio evaluates `--field` with `--from`/`--to`, and `--partition`, against the part list before it wakes a worker. They are never SQL. The bounds travel as the text you typed. A part records its bounds in the manifest's canonical text encoding, and this command compares your text against that encoding.

A prune belongs to the question, so the same SQL over a different slice is a new query rather than a replay.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `RUN_ID` | id | yes | The run whose table to ask. | none |
| `SQL` | statement | yes | One read-only statement beginning with `SELECT`, `WITH`, `EXPLAIN` or `DESCRIBE`. | none |
| `--max-rows` | `N` | no | Stop at this many rows. Studio refuses a number over its ceiling rather than lowering it. | the server's own ceiling |
| `--field` | column name | with `--from`/`--to` | Which field `--from` and `--to` are about. Alone it narrows nothing, so the command refuses it. | none |
| `--from` | value | no | Open only the parts whose recorded range on `--field` reaches this value or later. Inclusive. A part the prune cannot rule out stays in. | none |
| `--to` | value | no | Open only the parts whose recorded range on `--field` starts before this value. Exclusive, so a day range runs from one midnight to the next. | none |
| `--partition` | `NAME=VALUE` | no | Open only the parts whose partition `NAME` matches one of the values given. Repeatable. A part carrying no value for `NAME` stays in. | none |
| `--no-session` | none | no | Refuse when no warm worker holds the workspace, rather than open a research session for the run's dataset. | off. It opens one, waits, asks and closes it |
| `--no-activity` | none | no | Accepted, and has no effect from 0.27.1: this command tells the dataset page nothing. | off |
| `--json` | none | no | One JSON object instead of human lines. | off |

```json title="mr-data query RUN_ID 'SELECT …' --json"
{
  "schema_version": "mostlyright-thin-client-v4-query.v1",
  "lane": "hosted",
  "run_id": "7c9e1d34-8b52-4f61-9a03-2e5d6f7a8b90",
  "query_id": "2f6b8d05-4e17-5a92-8c63-b0d5e1f7a934",
  "dashboard_url": "https://mostlyright.md/datasets",
  "replayed": false,
  "polls": 3,
  "status": "query_answered",
  "state": "succeeded",
  "sql": "SELECT city, max(max_temp_c) FROM run_table GROUP BY city",
  "max_rows": 100,
  "prune": { "field": "date", "from": "2026-09-01", "to": "2026-09-12" },
  "part_count": 11,
  "row_count": 20,
  "truncated": false,
  "elapsed_ms": 84,
  "result_digest": "a1b2…",
  "rows": { "row 1": { "city": "Boston", "max": "31.2" } }
}
```

Values are illustrative. `replayed` reads Studio's `Idempotent-Replay` header. `part_count` is how much of the table the answer covers. `max` is the maximum of a `DOUBLE` column, so it comes back as text.

A column that carries a timestamp with a time zone comes back as UTC instants with a `+00:00` offset, such as `2026-09-17T20:52:00+00:00`. An aggregate such as `max(observed_at)` answers the same way, and so does a timestamp nested in a list, a struct or a map.

Floats, intervals and very large integers come back as text. None of them needs a cast:

| Value | Answer |
| --- | --- |
| `DOUBLE` | Its shortest round-trip text, such as `"31.2"`. NaN, the infinities and negative zero come back as `"NaN"`, `"Infinity"`, `"-Infinity"` and `"-0.0"`. |
| `FLOAT` | The engine's own text, such as `"0.1"`. |
| `INTERVAL` | The engine's own text, with its months kept, such as `"1 month"`. |
| An integer past ±(2^53 − 1) | Its digits as a string, such as `"9007199254740993"`. Smaller integers stay JSON numbers, so one column can mix the two. |

A value that cannot be rendered exactly is refused as `THIN_QUERY_RESULT_INEXACT`, which names the column.

When the wait runs out, `status` reads `query_still_running` and the command exits `2`. The payload carries `state`, `sql`, `max_rows`, `deadline_at` and a `note`. The query keeps running and Studio terminalizes it at its own deadline. Run the same command again to read it back.

Errors:

| Code | When |
| --- | --- |
| `THIN_QUERY_STATEMENT_TOO_LARGE` | The submission is over 64 KiB. The command sent nothing. |
| `THIN_REQUEST_INVALID` | An empty statement. A `--max-rows` under 1. `--from`/`--to` without `--field`. `--field` without either. A `--partition` that is not `NAME=VALUE`. |
| `THIN_STUDIO_QUERY_STATEMENT_FORBIDDEN` | Studio refused the statement before queuing it. See [The statement](#the-statement). |
| `THIN_STUDIO_QUERY_ROW_LIMIT_EXCEEDED` | `--max-rows` sits over the server ceiling. Leave it out to take the ceiling. |
| `THIN_STUDIO_QUERY_TABLE_UNAVAILABLE` | This run wrote no table, or the window and partitions narrowed it to no part at all. |
| `THIN_STUDIO_QUERY_TOO_MANY_PARTS` | The question would open more parts than a bounded read may. The refusal names both the count and the ceiling. Narrow it, or use `parts` and read them yourself. |
| `THIN_STUDIO_QUERY_NO_WARM_EXECUTOR` | You gave `--no-session` and no warm worker holds the workspace. Studio does not start one for a query. |
| `THIN_QUERY_RESULT_TOO_LARGE` | The answer was too large or nested too deep, so it was refused rather than shortened. Narrow the projection, add a `WHERE`, lower `--max-rows`, or cast a deeply nested column to text. |
| `THIN_QUERY_RESULT_INEXACT` | The answer holds a value that cannot be rendered exactly, such as a `FLOAT`, an `INTERVAL` or a zoned timestamp inside a union. The refusal names the column. Cast that column to text in the statement. |
| `THIN_QUERY_RESULT_REFUSED` | Studio refused to record the answer, so the query failed at once instead of waiting out its deadline. The refusal says why and what to do. Running the same command again runs the query again. |
| `THIN_QUERY_TIMEOUT` | The query outlived its deadline. No partial result is recorded. |
| `THIN_QUERY_DOWNLOAD_TIMEOUT` | The deadline ran out while the worker was still downloading the parts the question selected, so the statement never ran. The download and the statement share one deadline. The refusal names the deadline and how many parts had arrived. It also names how long the query waited to be picked up, when that was half a second or more. When the parts could not fit even the whole deadline at the rate they arrived, it says to narrow the question with a prune: `--field` plus `--from`/`--to`, or `--partition`. Otherwise running the same command again runs the query again. A `WHERE` does not shrink the download. |
| `THIN_QUERY_V4_QUERY_TABLE_UNREACHABLE` | The worker could not download the table. Storage answered with an error, sent nothing, or the connection failed before or partway through the download. The refusal names the HTTP status or the failure, as in `the table download failed (…)`. Running the same command again runs the query again. When the refusal says the run's table is larger than a bounded query may open, asking again does not help. |
| `THIN_RESEARCH_SESSION_ENDED` | No worker took the session the command opened. When Studio settles that session `failed` for want of a worker (`RESEARCH_SESSION_WORKER_UNAVAILABLE`), from 0.27.1 the command waits 8 seconds and opens one more, once. If no worker takes that one either, this refusal names both sessions with their state, close reason and failure code. Any other ending, such as a session still waiting for a worker when the wait ran out, is refused at once and names the one session. Nothing was queued. |
| `THIN_RESPONSE_INVALID` | Studio queued a query without an identifier, or returned a result belonging to another run. |

```bash title="one day of one table, without opening the whole version"
mr-data query "$RUN_ID" \
  'SELECT city, max(max_temp_c) AS max_c FROM run_table GROUP BY city ORDER BY max_c DESC' \
  --field date --from 2026-09-11 --to 2026-09-12 --max-rows 100 --json
```

## download

```bash
mr-data download RUN_ID [--output DIR] [--kind KIND …] [--no-activity] [--json]
```

Bring one run's artifacts back to this computer and check each digest. A table version made of parts arrives whole. The manifest names every part of the version, including the parts earlier runs wrote. The command fetches each one through the version's own signed reads. If a part cannot be fetched, the refusal names how many failed. The command never leaves a folder holding only some of the table.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `RUN_ID` | id | yes | The run to read. | none |
| `--output` | `DIR` | no | The folder to write into. Relative or absolute, with `~` expanded. The command overwrites nothing in it. | `run-artifacts` |
| `--kind` | one kind | no | Which artifacts to bring back, one `--kind` each. | the shape the run wrote |
| `--no-activity` | none | no | Accepted, and has no effect from 0.27.1: this command tells the dataset page nothing. | off |
| `--json` | none | no | One JSON object instead of human lines. | off |

| `--kind` | File suffix |
| --- | --- |
| `table_parquet` | `.parquet` |
| `table_manifest` | `.json` |
| `table_part` | `.parquet` |
| `column_profile` | `.json` |
| `preview` | `.json` |
| `receipt` | `.json` |
| `raw_snapshot` | `.tar` |

Without `--kind` the command reads the listing and brings back the shape the run wrote. That is `table_manifest` plus `table_part` plus `receipt` for a version made of parts, and `table_parquet` plus `receipt` for one written whole. It never brings all seven. A profile, a preview and a tar of raw sources each have their own command that reads without writing a file.

```json title="mr-data download RUN_ID --json"
{
  "schema_version": "mostlyright-thin-client-v4-download.v1",
  "status": "artifacts_downloaded",
  "lane": "hosted",
  "run_id": "7c9e1d34-8b52-4f61-9a03-2e5d6f7a8b90",
  "dashboard_url": "https://mostlyright.md/datasets",
  "output": "run-artifacts",
  "artifacts": [
    {
      "artifact_id": "5e2a9c74-1d6b-4308-b9c5-8f0a3d7e2b41",
      "kind": "table_part",
      "media_type": "application/vnd.apache.parquet",
      "size_bytes": 6291456,
      "digest": "9f3c…",
      "path": "run-artifacts/part-00001.parquet",
      "transferred": true
    }
  ],
  "version": {
    "table_version_id": "9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37",
    "manifest_digest": "a1b2…",
    "part_count": 128,
    "row_count": 4100000,
    "parts_sealed_by_earlier_runs": 96
  }
}
```

Values are illustrative. `version` appears only for a version made of parts. `parts_sealed_by_earlier_runs` counts how many of them this run did not write.

Errors:

| Code | When |
| --- | --- |
| `THIN_REQUEST_INVALID` | A `--kind` outside the seven. The refusal lists all seven. |
| `THIN_DESTINATION_EXISTS` | A file this download would write is already there. The command overwrites nothing. |
| `THIN_DOWNLOAD_DIGEST_MISMATCH` | Bytes arrived that do not hash to the digest the listing or the manifest stated. |
| `THIN_DOWNLOAD_TRUNCATED` / `THIN_DOWNLOAD_OVERRUN` | The transfer ran shorter or longer than the size stated. |
| `THIN_VERSION_PART_UNREACHABLE` | One or more parts of the version did not arrive. The refusal names how many. |
| `THIN_NO_MATCHING_ARTIFACT` | The run wrote nothing of the kinds asked for. |
| `THIN_DOWNLOAD_REFUSED` | The signed read came back refused. |

```bash
mr-data download "$RUN_ID" --output ./data --kind table_manifest --kind table_part --json
```

## parts

```bash
mr-data parts TABLE_ID [--version VERSION] [--field FIELD] [--from VALUE] [--to VALUE] [--json]
```

List one table version's parts: the identifier, the row count, the byte size and the recorded bounds of each. A training loader shards this list. Give each worker the parts whose index modulo the world size is its own, open each with `pyarrow.parquet.ParquetFile`, and iterate its batches.

Listing downloads nothing, and the listing does not prove a part still reads. Whatever reads a part checks its bytes against the digest stated here.

| Flag | Argument | Required | Meaning | Default |
| --- | --- | --- | --- | --- |
| `TABLE_ID` | id | yes | The table to read. | none |
| `--version` | `VERSION` | no | Which version to list, by its identifier. | the live one, or the pinned one on a frozen table |
| `--field` | column name | no | Which field `--from` and `--to` are about. | the version's first sort field |
| `--from` | value | no | Keep only the parts whose recorded range reaches this value or later. Inclusive. | none |
| `--to` | value | no | Keep only the parts whose recorded range starts before this value. Exclusive. | none |
| `--json` | none | no | One JSON object instead of human lines. | off |

The window is half-open and lower-inclusive, the rule the query path uses. A part whose range starts exactly at `--to` drops out, and one whose range ends exactly at `--from` stays in. A part that records no bound on the field, or half of one, stays in. Pruning never drops a part it cannot rule out.

```json title="mr-data parts TABLE_ID --json"
{
  "schema_version": "mostlyright-thin-client-v4-table-parts.v1",
  "status": "parts_listed",
  "lane": "hosted",
  "table_id": "3f8c2d10-6a4b-4c8e-9d1f-5b7a0c2e4d68",
  "table_version_id": "9d4e7a21-0c3b-4f56-8e2a-1b6c5d9f0a37",
  "table_version_number": 42,
  "manifest_digest": "a1b2…",
  "row_count": 4100000,
  "part_count": 128,
  "total_bytes": 811000000,
  "partition_fields": ["city"],
  "sort_fields": ["date"],
  "parts": [
    {
      "part_id": "9f3c…",
      "run_id": "7c9e1d34-8b52-4f61-9a03-2e5d6f7a8b90",
      "artifact_id": "5e2a9c74-1d6b-4308-b9c5-8f0a3d7e2b41",
      "part_key": "p00001",
      "file_format": "parquet",
      "file_size_in_bytes": 6291456,
      "record_count": 32000,
      "schema_digest": "a1b2…",
      "partition": { "city": "Boston" },
      "lower_bounds": { "2": "2026-09-01" },
      "upper_bounds": { "2": "2026-09-11" },
      "null_value_counts": {},
      "sealed_at": "2026-09-12T09:00:00Z"
    }
  ],
  "parts_listed": 11
}
```

Values are illustrative. `part_count` is the version's total. `parts_listed` is how many survived the window. Part rows come from the manifest, verbatim. `part_id` is the part's content digest and its identity everywhere. `artifact_id` only says how to address its bytes. A version one run composed can name parts written by runs before it. Bounds carry a field id and the manifest's canonical text encoding, which is what `--from` and `--to` compare against.

Errors:

| Code | When |
| --- | --- |
| `THIN_NOTHING_LIVE` | The table is unpromoted or demoted, so no live version exists to list. Or it is promoted and no succeeded run of it resolves to a version. Name one with `--version`. The run that wrote a version carries its identifier, and `mr-data status` prints it. |
| `THIN_RESPONSE_INVALID` | The version's manifest states no part list, or states more parts than a version may name. |
| `THIN_REQUEST_INVALID` | `--from`/`--to` with no `--field` on a version that declares no sort field, or a `--field` the version does not carry. The refusal lists the fields it does carry. |
| `THIN_NOT_FOUND` | No such table, or no such version of it. |

```bash title="shard one day across four workers"
mr-data parts "$TABLE_ID" --field date --from 2026-09-11 --to 2026-09-12 --json \
  | jq -r --argjson world 4 --argjson rank 0 \
      '.parts | to_entries[] | select(.key % $world == $rank) | .value.part_key'
```

## See also

- [CLI reference](/docs/reference/cli/): conventions, exit codes, environment variables
- [Runs](/docs/reference/cli/run/): the run these four read from
- [Tables](/docs/reference/cli/table/): which version is live, and which is pinned
