# Run a SQL query

Execute ad-hoc SQL against your Stripe data.

You can execute SQL against your Stripe data by creating a `QueryRun`. Use the same SQL syntax as the [Sigma query editor](https://docs.stripe.com/data/write-queries.md) to automate extracts, run queries on your own schedule, and load results into your systems.

A `QueryRun` is an asynchronous job. You send SQL, Stripe starts executing it, and you [retrieve the QueryRun](https://docs.stripe.com/data/api/queries.md#retrieve-query-run) later to get a CSV or inline rows. Learn more about [runs](https://docs.stripe.com/data/api.md#concepts).

## Required permissions 

To create and retrieve query runs, grant **Data** > **Queries** > **Read** (`data_queries_read`) on a [restricted API key](https://dashboard.stripe.com/apikeys).

To see the tables and columns you can query, [browse table schemas](https://docs.stripe.com/data/api/schemas.md).

Query runs are free through January 31, 2027. Usage-based pricing begins February 1, 2027.

## Get SQL to run 

Put a SQL string in `query.sql`. Sources that work well:

- A query you’ve already written or saved in the [Sigma query editor](https://docs.stripe.com/data/write-queries.md)
- A natural-language question to a coding agent through the [Stripe MCP server](https://docs.stripe.com/data/analyze-with-ai.md), such as “Total my refunds by month for this year”
- A `SELECT` against a table from [Browse table schemas](https://docs.stripe.com/data/api/schemas.md)

The example below queries `balance_transactions`. Swap in any table and columns your schema list returns.

## Create a QueryRun

Send a POST request with your SQL in `query.sql`. Stripe starts the query and returns a `QueryRun` immediately. A new run has `status=running` and `result=null`. If an identical run already exists, Stripe returns that run instead (see [Understand cached runs](https://docs.stripe.com/data/api/queries.md#cached-runs)). The returned run might already have `status=succeeded` with a result you can use right away. Check `status` in the create response before you poll or wait for a webhook. Save the `id`, then [retrieve the QueryRun](https://docs.stripe.com/data/api/queries.md#retrieve-query-run) when it completes.

```curl
curl -X POST https://api.stripe.com/v2/data/query_runs \
  -H "Authorization: Bearer <<YOUR_SECRET_KEY>>" \
  -H "Stripe-Version: 2026-10-28.preview" \
  --json '{
    "query": {
        "sql": "SELECT * FROM balance_transactions LIMIT 10"
    },
    "dataset": "analytical",
    "format": "csv"
  }'
```

```json
{
  "id": "qrun_123",
  "object": "v2.data.query_run",
  "created": "2026-07-03T01:02:29.964Z",
  "query": {
    "sql": "SELECT * FROM balance_transactions LIMIT 10"
  },
  "dataset": "analytical",
  "format": "csv",
  "status": "running",
  "result": null,
  "result_options": {
    "compression": "none"
  },
  "livemode": false
}
```

Use the returned `id` to [retrieve the QueryRun](https://docs.stripe.com/data/api/queries.md#retrieve-query-run) after it completes.

## Retrieve a QueryRun

Retrieve the `QueryRun` using the `id` returned in the create response. If you poll, continue until `status` is `succeeded`, `failed`, or `canceled`. Stripe might add new statuses, so don’t assume this set of values is fixed. When `status` is `succeeded`, download the file from `result.file.download_url.url`. When `status` is `failed`, read [status_details](https://docs.stripe.com/data/api/queries.md#handle-a-failed-query). You can also [listen for webhooks](https://docs.stripe.com/data/api/queries.md#webhooks) instead of polling.

```curl
curl https://api.stripe.com/v2/data/query_runs/qrun_123 \
  -H "Authorization: Bearer <<YOUR_SECRET_KEY>>" \
  -H "Stripe-Version: 2026-10-28.preview"
```

```json
{
  "id": "qrun_123",
  "object": "v2.data.query_run",
  "created": "2026-07-03T01:02:29.964Z",
  "query": {
    "sql": "SELECT * FROM balance_transactions LIMIT 10"
  },
  "dataset": "analytical",
  "format": "csv",
  "status": "succeeded",
  "result": {
    "file": {
      "content_type": "csv",
      "download_url": {
        "expires_at": "2026-07-03T01:10:46.679Z",
        "url": "https://stripeusercontent.com/files/us-west-2/download/wksp_123/file_123/qrun_123.csv..."
      },
      "size": "512"
    }
  },
  "result_options": {
    "compression": "none"
  },
  "livemode": false
}
```

> The download URL expires after 5 minutes. Retrieve the `QueryRun` again to get a new URL.

Stripe retains a `QueryRun` and its results for 90 days after the run completes. After 90 days, the results expire and attempts to retrieve the `QueryRun` return a `404` error. To regenerate the results, create a new `QueryRun`. To retain results for longer than 90 days, download and store them.

## Page results inline

To read rows in the API response instead of downloading a file, retrieve the run with `include[0]=result.inline`. Use `limit` (default 100, maximum 1,000) and follow `next_page_url` to page through rows. Keep the same `limit` on every request in a pagination sequence. To use a different `limit`, start pagination again without a page token.

```curl
curl -G https://api.stripe.com/v2/data/query_runs/qrun_123 \
  -H "Authorization: Bearer <<YOUR_SECRET_KEY>>" \
  -H "Stripe-Version: 2026-10-28.preview" \
  -d "include[0]=result.inline" \
  -d limit=100
```

```json
{
  "id": "qrun_123",
  "object": "v2.data.query_run",
  "status": "succeeded",
  "result": {
    "inline": {
      "columns": [
        {"name": "id", "type": "varchar"},
        {"name": "amount", "type": "bigint"}
      ],
      "rows": [
        {
          "data": {
            "id": "txn_123",
            "amount": "1000"
          }
        }
      ],
      "next_page_url": null,
      "previous_page_url": null
    },
    "row_count": "1",
    "col_count": "2"
  },
  "livemode": false
}
```

## Understand cached runs 

We cache query runs to avoid repeating the same execution. A matching create request can return an existing `QueryRun` with the same `id` and `created` timestamp instead of creating another run.

A cache match requires all of the following:

- The same Stripe account or [organization](https://docs.stripe.com/get-started/account/orgs.md), and the same set of accounts you’re authorized to query.
- The same live mode or [sandbox](https://docs.stripe.com/sandboxes.md) context.
- The same SQL text. Stripe compares your SQL exactly as submitted and doesn’t normalize it. Changes to capitalization, comments, literal values, or whitespace create a new run.

- The same `format` and `result_options.compression` settings.
- The same `refreshed_at` value. This value indicates when Stripe last refreshed the data used by the run. After Stripe refreshes your data, the next matching request creates a new run with the updated data.
- A run that’s still `running` or that `succeeded` less than seven days ago.

The returned run is either `succeeded` or still `running` because an identical run is in progress.

Reusing a run doesn’t emit another `v2.data.query_run.created` event. If the returned run is already `succeeded`, use its results without waiting for another completion event.

## Webhooks 

Instead of polling, listen for `v2.data.query_run.succeeded` when the query succeeds or `v2.data.query_run.failed` when it fails. After receiving the event, retrieve the `QueryRun` and read the result URL or [status_details](https://docs.stripe.com/data/api/queries.md#handle-a-failed-query). Before processing these events, [verify incoming webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) and review the Stripe [public IP addresses](https://docs.stripe.com/ips.md).

## Handle a failed query 

If a query fails after you create the run, the `QueryRun` remains available with `status` set to `failed`. Because the create request succeeded, the query failure doesn’t return an HTTP error. Retrieve the [QueryRun](https://docs.stripe.com/api/v2/data/query-runs/object.md) and read `status_details.code` and `status_details.message`. For invalid SQL, `status_details.query_errors` describes each error.

If SQL validation fails before the run starts, no `QueryRun` is created. The request returns HTTP `400` with the code `query_run_invalid_sql`, and the error `message` describes the problem, for example `Syntax error at line 1:1: mismatched input 'SELEC'. Queries must start with SELECT or WITH.`

|  |
| `query_run_invalid_sql` | The SQL passed initial validation but has a syntax or semantic error, such as an unknown column. Each error is in `query_errors`, with a `type` (`unknown_column` or `invalid_sql`) and a `message`. |
| `execution_failed` | The SQL is valid but failed while it was evaluated, for example because of a division by zero or an invalid cast. |
| `file_size_above_limit` | The result exceeds the 5 GB file limit. Narrow the query or create the run with `result_options.compression` set to `zip`. |
| `resource_limit_exceeded` | The query reached a resource limit. The `message` names the limit, such as memory, execution time, or query complexity, and suggests how to reduce the query. |
| `internal_error` | The query failed for a reason that another code doesn’t cover. The `message` describes the failure. |

```json
{
  "id": "qrun_123",
  "object": "v2.data.query_run",
  "status": "failed",
  "result": null,
  "status_details": {
    "code": "query_run_invalid_sql",
    "message": "Your query contains invalid SQL. Please check your SQL and try again.",
    "query_errors": [
      {
        "type": "unknown_column",
        "message": "Column 'nonexistent_col' cannot be resolved"
      }
    ]
  }
}
```

## Request file compression 

For large result sets, set `result_options.compression` to `zip` to receive a ZIP-compressed file.

```curl
curl -X POST https://api.stripe.com/v2/data/query_runs \
  -H "Authorization: Bearer <<YOUR_SECRET_KEY>>" \
  -H "Stripe-Version: 2026-10-28.preview" \
  --json '{
    "query": {
        "sql": "SELECT * FROM balance_transactions"
    },
    "dataset": "analytical",
    "format": "csv",
    "result_options": {
        "compression": "zip"
    }
  }'
```

## Use an Organization API key 

The Queries and Reports API accepts [Organization API keys](https://docs.stripe.com/keys/organization-api-keys.md).

### Run a query across your organization

Omit `Stripe-Context` to run in organization mode, equivalent to [Sigma for Organizations](https://docs.stripe.com/data/sigma-organizations.md). Your SQL can read data from every direct account, and an `account` column identifies the account for each row.

```curl
curl -X POST https://api.stripe.com/v2/data/query_runs \
  -H "Authorization: Bearer sk_org_123" \
  -H "Stripe-Version: 2026-10-28.preview" \
  --json '{
    "query": {
        "sql": "SELECT account, id, amount FROM balance_transactions LIMIT 10"
    },
    "dataset": "analytical",
    "format": "csv"
  }'
```

### Run a query scoped to a single account

Set the [Stripe-Context](https://docs.stripe.com/context.md) header to the account you want to query.

```curl
curl -X POST https://api.stripe.com/v2/data/query_runs \
  -H "Authorization: Bearer sk_org_123" \
  -H "Stripe-Version: 2026-10-28.preview" \
  -H "Stripe-Context: {{CONTEXT_ID}}" \
  --json '{
    "query": {
        "sql": "SELECT * FROM balance_transactions LIMIT 10"
    },
    "dataset": "analytical",
    "format": "csv"
  }'
```

## Limits 

For general details about APIs in the v2 namespace, see the [API v2 overview](https://docs.stripe.com/api-v2-overview.md). For general API rate limits, see [Rate limits](https://docs.stripe.com/rate-limits.md). Specific limits include:

|  |
| Query run creation rate | You can create 10 `QueryRun` objects per second in live mode, and 1 per second in a *sandbox* (A sandbox is an isolated test environment that allows you to test Stripe functionality in your account without affecting your live integration. Use sandboxes to safely experiment with new features and changes). This limit applies per account or organization. Requests that exceed the limit return a `429` status code with the code `query_run_submit_rate_limit_exceeded`. Retry with exponential backoff. |
| Concurrent query runs | You can have 100 `QueryRun` objects in the `running` state at the same time in live mode, and 10 in a sandbox. This limit applies per account or organization. Wait for in-progress query runs to finish before creating new ones. |
| SQL length | `query.sql` can be up to 100,000 characters. Longer SQL returns a `400` status code. |
| Query execution timeout | Queries that run longer than 90 minutes fail, and the `QueryRun` moves to `status=failed` with `status_details.code` set to `resource_limit_exceeded`. See [Handle a failed query](https://docs.stripe.com/data/api/queries.md#handle-a-failed-query). |
| File size | 5 GB per result file. If the result exceeds this limit, the run fails with `status_details.code` set to `file_size_above_limit`. Set `result_options.compression` to `zip` if you reach this limit. See [Handle a failed query](https://docs.stripe.com/data/api/queries.md#handle-a-failed-query). |
| File retention | Stripe retains a `QueryRun` and its results for 90 days after the run completes. After 90 days, the results expire. To regenerate the results, create a new `QueryRun`. |
| Download URL lifetime | File download URLs expire after 5 minutes. |

Requests that exceed the query run creation rate or concurrent query run limits return a `429` status code. Stripe also limits query runs across all accounts. This means you can receive a `429` even when you’re within your own limits. Retry with exponential backoff.

## See also

- [Browse table schemas](https://docs.stripe.com/data/api/schemas.md)
- [Access Stripe data with the API](https://docs.stripe.com/data/api.md)
- [Run a report](https://docs.stripe.com/data/api/reports.md)
- [Write queries using Sigma](https://docs.stripe.com/data/write-queries.md)
- [Analyze your Stripe data with AI](https://docs.stripe.com/data/analyze-with-ai.md)
