---
title: introspect_schema
description: List the tables and columns a cube SQL may read from a connection, with row estimates and semantic roles. Parameters, errors and scope.
updated: 2026-09-20
tools: [introspect_schema]
---

Lists the tables and columns a cube SQL may read from a connection, and **only**
those. It never exposes the full database. This is step one of authoring a
refreshable dashboard: read the columns, pick low-cardinality dimensions and
additive measures, then write and validate the SQL with
[`validate_cube_sql`](/reference/mcp-tools/validate-cube-sql).

Behaviour hints for clients: read-only, not open-world.

## Parameters

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `connection` | string | no | `self` (the default), a warehouse connection id from `list_connections`, or the uploaded-file source id (which also needs `upload`). |
| `tables` | array | no | Filter terms. Each is matched case-insensitively against any part of the fully-qualified table name. |
| `upload` | string | no | Which uploaded file to read, as an upload id from [`create_file_upload`](/reference/mcp-tools/create-file-upload). **Required when `connection` is the workspace's uploaded-file source, and refused on any other connection** - a warehouse has no uploads. There is no default and the newest upload is not assumed: a later upload would otherwise silently change what this call answers and what a published dashboard reads, so omitting it is refused with the newest ready upload named for you to pass back. |

:::tip{title="Narrow with tables once you know what you are building"}
On a real warehouse an unfiltered introspect returns every readable table with
every column, which is the single largest result in the authoring flow and mostly
not about your dashboard. Because a term matches any part of the qualified name,
one argument serves as a database, schema or table filter: on Snowflake `SALES`
narrows to that database and `SALES.PUBLIC` to that schema; on Postgres and
`self` the names are bare, so pass the table name. Omit it the first time, when
you genuinely want to see what is there.
:::

## Returns

```text
Readable schema for connection "self".
Table: dashies_usage_metrics
- day: date - dimension: the UTC day the metric was recorded
- publishes: bigint - flow: dashboards published that day
- active_dashboards: bigint - stock: dashboards live at the end of that day

BEGIN_JSON
{"connection":"self","tables":[{"name":"dashies_usage_metrics","columns":[{"name":"day","type":"date","role":"dimension","description":"..."}]}]}
END_JSON
```

With a filter applied the header instead reads
`Readable schema for connection "<c>" - 3 of 214 tables matching "orders".`

| Field | Meaning |
|---|---|
| `role` | `dimension`, `flow` or `stock`. **`self` only**; warehouse columns carry no role yet. |
| `row_estimate` | Rendered as `Table: <name> (~<n> rows)`. Warehouse only. |
| `size_band` | `small`, `big` or `extreme`. |
| `recommended_mode` | `inline`, `parquet` or `reject`. |

:::danger{title="Never SUM a stock column"}
`flow` accumulates across the grain and is safe to sum. `stock` is a level
measured at one instant, and summing it across a time grain recounts the same
entities every period. A cohort cube summing ending ARR over 24 tenure months
once reported 596 million dollars against a real 36 million. Only `self` columns
carry the role, so on a warehouse you have to know this yourself, and declare it
with `stock_columns` when you validate.
:::

The row estimate is the remote planner's approximation, captured when the
connection was created or last resynced. Treat it as a scale signal, never an
exact count. A table with no estimate may simply be unanalyzed on the remote; the
fast exact check is `validate_cube_sql` with
`select count(*) as n from <table>`, which pushes down to the remote and stays
fast at millions of rows.

:::note{title="It returns column names, not column values"}
Before writing a filter predicate or a conditional count against an enum-like
column, confirm its real values with a quick `validate_cube_sql` that groups by
that column. Introspection cannot tell you what is in a column.
:::

## Errors

The shared [connection rules](/reference/mcp-tools/conventions#connections), plus:

| Condition | Text |
|---|---|
| The schema read failed | `Could not read the schema for connection "<c>": <err>` |
| Nothing readable, warehouse | `No readable tables are available for connection "<c>". The connection has no readable tables yet - check the imported schema(s) and that the read-only role can SELECT them.` |
| Nothing readable, `self` | `No readable tables are available for connection "<c>". The refresh allowlist may be misconfigured.` |
| A filter that matches nothing | `No readable table on connection "<c>" matches "<f>". Each filter is matched case-insensitively against the fully-qualified table name, so any part of one works. Available: <up to 40 names> (+<n> more).` |
| A malformed filter | `tables must be an array of table-name filters (or a single string) - each is matched case-insensitively against the fully-qualified table name.` |
| An empty filter array | `tables must contain at least one filter - omit it entirely to list every readable table.` |

A filter that matches nothing lists what is actually there, so the fix is one
corrected call rather than an unfiltered re-list of the whole warehouse.

## Scope

Connection-scoped, so there is **no `workspace` argument**. It accepts any
connection belonging to a workspace you are a current member of, plus the built-in
`self` and the shared sample. Using a warehouse connection requires a paid plan;
`self` does not.

## Check it worked

Every column your statement will name should appear in the listing with the type
you expect: the ones you break down by, and the ones each number is worked out
from. If a table you know exists is missing, check the
connection's imported schemas in the web app rather than assuming the filter is
wrong, and re-run without `tables` to see the full set.
