datasette/existing-api.md
Claude e8048e023f
Return 400 for write canned-query SQL failures
POST to a write canned query previously returned HTTP 200 with
{"ok": false, "message": ...} when the SQL failed to execute, so JSON
clients (and anything that trusts HTTP status) recorded success for
failed writes. SQL failures now return 400 with the canonical error
shape plus the "redirect" context key from on_error_redirect; the
QueryWriteRejected 403 branch uses the canonical shape too. Successful
executions and the HTML flash-message flow are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:35:54 +00:00

1223 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Datasette JSON API — As Implemented
This document describes the JSON API of this Datasette codebase (version `1.0a35`) as
derived directly from the source code. It intentionally ignores the existing `docs/`
directory: every claim below is based on the route table in `datasette/app.py`
(`Datasette._routes()`, app.py:2507-2767) and the view implementations in
`datasette/views/`.
## Contents
- [Cross-cutting behavior](#cross-cutting-behavior)
- [Instance endpoints](#instance-endpoints)
- [Database endpoints](#database-endpoints)
- [Table and row read endpoints](#table-and-row-read-endpoints)
- [The write API](#the-write-api)
- [Stored (canned) queries API](#stored-canned-queries-api)
- [Authentication and tokens](#authentication-and-tokens)
- [Appendix: registered actions (permissions)](#appendix-registered-actions-permissions)
---
## Cross-cutting behavior
### URL formats and content negotiation
- Most read endpoints are registered with an optional format suffix:
`/(...)(\.(?P<format>json))?$`. The bare path returns HTML; the `.json`
extension returns JSON.
- Table, row and query routes accept any `\w+` format extension; formats other
than the built-in `html`, `json`, `csv`, `blob` must be provided by a plugin
via `register_output_renderer`, otherwise the request 404s.
- HTML responses include a `Link: <...>; rel="alternate";
type="application/json+datasette"` header pointing at the `.json` variant
(views/base.py:141-159), unless the view opts out with
`has_json_alternate = False`.
- Database, table, row and query names in paths are **tilde-encoded**
(a percent-encoding variant using `~` as the escape character;
utils/__init__.py `_TILDE_ENCODING_SAFE`). Multi-column primary keys in row
URLs are comma-separated.
- JSON responses are always compact `json.dumps` output serialized by
`CustomJSONEncoder`; there is no pretty-printing query parameter. Binary
values are serialized as `{"$base64": true, "encoded": "..."}`.
- Success content type: `application/json; charset=utf-8`
(`_shape=array&_nl=on` responses use `text/plain`).
### Success envelope
Every JSON endpoint that returns an object includes `"ok": true` on
success. `JsonDataView` injects it automatically for dict responses
(views/special.py); the homepage, jump, schema, permission-debug and
autocomplete views add it explicitly. The former top-level-array endpoints
(`/-/plugins`, `/-/databases`, `/-/actions`) now return objects wrapping
their arrays (`{"ok": true, "plugins": [...]}` etc.).
### Error shape (canonical)
Every JSON error response uses one canonical shape, built by `error_body()`
(utils/__init__.py):
```json
{
"ok": false,
"error": "all messages joined with '; '",
"errors": ["message", "..."],
"status": 404
}
```
- `errors` is a list of one or more message strings (multi-message
validation errors, e.g. per-row insert errors, list them all).
- `error` is the messages joined with `"; "`.
- `status` always matches the HTTP status code.
The shape is produced by four code paths, all delegating to `error_body()`:
1. **Exception handler** (handle_exception.py) — `NotFound`,
`DatasetteError`, `BadRequest` etc. on `.json` paths. `DatasetteError`
`error_dict` context keys are merged in; the legacy `title` key is no
longer emitted in JSON (it survives in the HTML error template context).
2. **The `_error()` helper** (views/base.py:183-184) — the write API,
stored-query API, execute-write and permission-denied paths.
3. **JSON renderer errors** (renderer.py) — SQL errors on table/query
endpoints return HTTP 400 with the canonical keys **plus** the context
keys of the response it could not produce:
```json
{"ok": false, "error": "no such table: x", "errors": ["no such table: x"],
"status": 400, "rows": [], "truncated": false}
```
Invalid `_shape=` values and `_shape=object` misuse (on queries or
pk-less tables) also return canonical 400 errors.
4. **Permission debug endpoints** (`/-/allowed`, `/-/rules`, `/-/check`,
POST `/-/permissions`) — canonical shape (previously bare
`{"error": ...}` objects).
Method-not-allowed responses return HTTP 405 with the canonical shape when
the path ends in `.json` or the request content type is `application/json`;
plain text otherwise (views/base.py).
**`Forbidden` handling:** when a view raises `Forbidden` (e.g. via
`ensure_permission`), the default `forbidden()` plugin hook returns the
canonical JSON error with status 403 when the path ends in `.json` or the
request carries an `Accept: application/json` / `Content-Type:
application/json` header; other requests get an HTML error page
(forbidden.py). Endpoints that check permissions themselves return
`_error(..., 403)` JSON directly.
### CORS
When Datasette is started with `--cors`, responses gain
(utils/__init__.py:1297-1302):
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Expose-Headers: Link
Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
Access-Control-Max-Age: 3600
```
### CSRF / cross-origin protection
Datasette uses header-based cross-origin protection
(`CrossOriginProtectionMiddleware`, csrf.py:67-178) rather than CSRF tokens
for API calls. For non-GET/HEAD/OPTIONS requests:
1. Requests carrying `Authorization: Bearer ...` **and no `Cookie` header**
bypass the check entirely (csrf.py:98-110).
2. Otherwise `Sec-Fetch-Site` must be `same-origin` or `none`; other values → 403.
3. If neither `Sec-Fetch-Site` nor `Origin` is present (curl, API clients),
the request passes.
4. Fallback: `Origin` must exactly match the request scheme/host/port → else 403.
Plain JSON API clients (no cookies, no browser headers) are never blocked;
`Content-Type: application/json` itself plays no role in the CSRF decision.
### Settings that govern the API
From `SETTINGS` (app.py:197-287): `default_page_size` (100),
`max_returned_rows` (1000), `max_insert_rows` (100), `sql_time_limit_ms`
(1000), `default_facet_size` (30), `facet_time_limit_ms` (200),
`allow_facet` (true), `allow_download` (true), `allow_signed_tokens` (true),
`default_allow_sql` (true), `max_signed_tokens_ttl` (0), `default_cache_ttl`
(5), `allow_csv_stream` (true), `max_csv_mb` (100), `force_https_urls`
(false), `trace_debug` (false), `base_url` ("/").
### The JSON renderer: `_shape`, `_nl`, `_json`, `_json_infinity`
`json_renderer` (renderer.py:31-126) processes `.json` output for table, row
and query views (but **not** for the instance/database/debug endpoints, which
build JSON directly):
- **`_shape`** (default `objects`):
- `objects` — `{"ok": true, "rows": [{col: val}, ...], "truncated": false, ...}`
- `arrays` — same envelope, each row a list of values
- `array` — response body is a bare JSON array of row objects
- `arrayfirst` — bare JSON array of the first column's values
- `object` — table views only: an object keyed by primary-key string.
On queries or tables without primary keys: a canonical 400 error
(`_shape=object is only available on tables` /
`_shape=object not available for tables with no primary keys`).
- anything else — canonical HTTP 400 error `Invalid _shape: x`
- **`_nl=on`** — with `_shape=array` only: newline-delimited JSON, `text/plain`.
- **`_json=COLUMN`** (repeatable) — parse that column's string values with
`json.loads` so they nest as JSON; parse failures leave the value unchanged.
- **`_json_infinity=1`** — preserve `Infinity`/`-Infinity`; by default they
are replaced with `null`.
- `columns` is stripped from dict-shaped output unless `?_extra=columns` was
requested (renderer.py:110-113).
- If a SQL error occurred, `_shape` is ignored, HTTP status is 400 and the
envelope carries the canonical error keys alongside `rows`/`truncated`.
### The `?_extra=` system
Table, row and query JSON responses support `?_extra=` (repeatable and/or
comma-separated, extras.py:9-14) to add keys to the response. Extras are
scope-registered (`ExtraScope.TABLE` / `ROW` / `QUERY`) and only **public**
extras are available over JSON (extras.py:73-92). Unknown extra names are
silently ignored. The available names per scope are listed with the relevant
endpoints below.
---
## Instance endpoints
Most of these are implemented with `JsonDataView` (views/special.py:30-79):
GET-only; bare path renders an HTML page (`show_json.html`), `.json` returns
the data; permission defaults to `view-instance` and denial raises
`Forbidden` → **HTML** 403 page.
### GET /
Routes: `/(\.(?P<format>jsono?))?$` and `/-/(\.(?P<format>jsono?))?$`
(app.py:2517-2518); `/-` permanently redirects to `/-/`. `IndexView`
(views/index.py:22-189). `GET /.json`, `/.jsono` and `/-/.json` return JSON.
- **Permission:** `view-instance` (denied → 403). Databases and tables are
further filtered by `view-database` / `view-table` for the actor.
- **Parameters:** `_sort=relationships` sorts each database's truncated table
list by foreign-key relationship count.
- **JSON response** (index.py:147-161) — includes `ok: true` plus:
- `databases` — an **object keyed by database name** (not a list). Each
value: `name`, `hash` (or null), `color`, `path`,
`tables_and_views_truncated` (up to 5 items: `name`, `columns`,
`primary_keys`, `count` (int or null), `hidden`, `fts_table`,
`num_relationships_for_sorting`, `private`; view items are just
`{"name", "private"}`), `tables_and_views_more` (bool), `tables_count`,
`table_rows_sum`, `show_table_row_counts`, `hidden_table_rows_sum`,
`hidden_tables_count`, `views_count`, `private`.
- `metadata` — instance metadata object.
### GET /-/versions(.json)
`JsonDataView` over `Datasette._versions` (app.py:2548-2551, 2171-2245).
Permission `view-instance`. No parameters.
Response keys: `python` (`{version, full}`), `datasette` (`{version}` plus
optional `note`), `asgi` (`"3.0"`), `uvicorn` (string or null), `sqlite`
(`{version, fts_versions, extensions, compile_options}`; `extensions`
includes `json1` and optionally `spatialite`), `pysqlite3` (only when
running under pysqlite3).
### GET /-/plugins(.json)
app.py:2552-2557, `Datasette._plugins` (app.py:2247-2266). Permission
`view-instance`.
- **Parameters:** `?all=1` — include Datasette's built-in default plugins
(filtered out by default).
- **Response:** `{"ok": true, "plugins": [...]}` — each plugin is
`{"name", "static", "templates", "version", "hooks"}`, sorted by name.
### GET /-/settings(.json)
app.py:2558-2561. Permission `view-instance`. No parameters. Returns a flat
object mapping every setting name (see [Settings](#settings-that-govern-the-api))
to its effective value.
### GET /-/config(.json)
app.py:2562-2565. Permission `view-instance`. No parameters. Returns the full
`datasette.yaml` configuration dict passed through
`redact_keys(config, ("secret", "key", "password", "token", "hash", "dsn"))`
(app.py:2502-2505) — any dict key containing one of those substrings has its
value replaced by `"***"` (utils/__init__.py:1532-1556).
### GET /-/threads(.json)
app.py:2566-2569, `Datasette._threads` (app.py:2268-2285). Permission
`view-instance`. No parameters.
Response: `num_threads`, `threads` (list of `{name, ident, daemon}`),
`num_tasks`, `tasks` (asyncio task repr strings). When the
`num_sql_threads` setting is 0 the response is exactly
`{"num_threads": 0, "threads": []}`.
### GET /-/databases(.json)
app.py:2570-2573, `Datasette._connected_databases` (app.py:2157-2169).
Permission `view-instance`. No parameters.
Response: `{"ok": true, "databases": [...]}` — each database is
`{"name", "route", "path", "size", "is_mutable", "is_memory", "hash"}`.
Only databases the actor is allowed to `view-database` are listed.
### GET /-/actor(.json)
app.py:2574-2579, registered with `permission=None` — **accessible to any
request including anonymous**. No parameters.
Response: `{"ok": true, "actor": {...}}` or `{"ok": true, "actor": null}` (app.py:2287-2288).
### GET /-/actions(.json)
app.py:2580-2589. Permission **`permissions-debug`**. No parameters.
Response: `{"ok": true, "actions": [...]}` — each action is
`{"name", "abbr", "description", "takes_parent", "takes_child",
"resource_class", "also_requires"}`, sorted by name (app.py:2290-2304).
### GET /-/auth-token
`AuthTokenView` (app.py:2590-2593, views/special.py:198-217). GET only, no
`.json` variant, HTML/redirect only.
- **Parameter:** `token` — the one-time secret printed by `datasette --root`.
- Match → invalidates the token, sets the signed `ds_actor` cookie to
`{"id": "root"}` and 302-redirects to the homepage. Mismatch or reuse →
`Forbidden` → 403 HTML.
### GET/POST /-/create-token
`CreateTokenView` (app.py:2594-2597, views/special.py:727-856). **HTML form
endpoint only — there is no JSON request/response mode in this codebase**
(`has_json_alternate = False`; the POST body must be form-encoded, a JSON
content type raises `BadRequest` → 400).
- **Gates** (each failure → `Forbidden` → 403): `allow_signed_tokens` must be
on; request must have an actor with an `id`; the actor must not itself be
token-derived.
- **POST fields:** `expire_type` (`""`/`minutes`/`hours`/`days`),
`expire_duration` (positive int), plus restriction checkboxes named
`all:<action>`, `database:<db>:<action>`,
`resource:<db>:<table>:<action>`.
- **Response:** HTML page containing the new `dstok_` token.
- Programmatic alternatives: `datasette create-token` CLI or
`datasette.create_token()`.
### GET /-/api
`ApiExplorerView` (app.py:2598-2601, views/special.py:859-1020). HTML API
explorer, GET only. Permission `view-instance` (403 on denial).
### GET /-/jump(.json)
`JumpView` (app.py:2602-2605, views/special.py:1023-1201). The route allows
an optional `.json` suffix but the view **always returns JSON**.
- **Permission:** none checked directly; results are filtered via
`allowed_resources_sql` for the current actor (default items come from the
`jump_items_sql` plugin hook).
- **Parameter:** `q` — whitespace-split terms matched as a case-insensitive
`%term1%term2%` LIKE pattern.
- **Response:** `{"ok": true, "matches": [...], "truncated": bool}`; each match:
`name`, `url`, `type` (`database`/`table`/`view`/`query`/plugin-defined),
`description`, optional `display_name`. Capped at 100 matches.
### GET /-/schema(.json|.md)
`InstanceSchemaView` (app.py:2610-2613, views/special.py:1257-1293).
- **Permission:** no explicit check; only databases the actor can
`view-database` are included (others silently omitted).
- **Formats:** no extension → HTML; `.json` →
`{"ok": true, "schemas": [{"database": name, "schema": "..."}]}`; `.md` →
`text/markdown` rendering.
### GET/POST /-/logout
`LogoutView` (app.py:2614-2617, views/special.py:220-238). HTML endpoint.
GET renders a confirmation page (or redirects if anonymous); POST deletes the
`ds_actor` cookie and 302-redirects to `/`.
### GET/POST /-/permissions
`PermissionsDebugView` (app.py:2618-2621, views/special.py:241-295). No
`.json` route. Both methods require `view-instance` **and**
`permissions-debug` (403 on denial).
- **GET** — HTML permission-check log; `?filter=all|exclude-yours|only-yours`.
- **POST** — form-encoded `actor` (JSON string), `permission`, optional
`resource_1`, `resource_2`; returns **JSON**
`{"action", "allowed", "resource": {"parent", "child", "path"}}` plus
`actor_id` when present. Errors: unknown action → 404; child without
parent → 400 (both canonical error shape).
### GET /-/allowed(.json)
`AllowedResourcesView` (app.py:2622-2625, views/special.py:298-460). Bare
path always renders the HTML form; `.json` returns JSON.
- **Permission:** none — reports the **current actor's own** allowed
resources. Items gain a `reason` field if the actor also holds
`permissions-debug`.
- **Parameters:** `action` (required; missing → 400 canonical error, unknown
→ 404), `parent`, `child` (requires `parent`), `page` (default 1),
`page_size` (default 50, silently capped at 200).
- **Response:** `{"action", "actor_id", "page", "page_size", "total",
"items": [{"parent", "child", "resource"}]}` with optional `next_url` /
`previous_url`.
### GET /-/rules(.json)
`PermissionRulesView` (app.py:2626-2629, views/special.py:463-584).
Permission `view-instance` **and** `permissions-debug`. Parameters and error
shapes as `/-/allowed`. Response items:
`{"parent", "child", "resource", "allow" (1|0), "reason", "source_plugin"}`.
### GET /-/check(.json)
`PermissionCheckView` (app.py:2630-2633, views/special.py:633-662).
Permission `permissions-debug`. Parameters `action` (required), `parent`,
`child`. Checks the **current request's actor**; response
`{"action", "allowed", "resource": {...}}` plus `actor_id`.
### GET/POST /-/messages
`MessagesDebugView` (app.py:2634-2637, views/special.py:703-724). HTML debug
tool for flash messages; permission `view-instance`; POST is form-encoded
(`message`, `message_type` = INFO/WARNING/ERROR/all) and 302-redirects.
### GET /-/allow-debug
`AllowDebugView` (app.py:2638-2641, views/special.py:665-700). GET only, HTML
only, **no permission required**. Parameters `actor` and `allow` (JSON
strings); renders the result of `actor_matches_allow()` in the page.
### GET /-/patterns
Pattern portfolio page (app.py:2642-2645). HTML only; not part of the JSON API.
### GET /-/debug/autocomplete
`AutocompleteDebugView` (app.py:2646-2649, views/special.py:94-195). HTML
debug page for the table autocomplete API; permission `view-instance` plus
`view-table` when `?database=&table=` are supplied.
---
## Database endpoints
### GET /\<database\>.db
Downloads the raw SQLite file. Route → `database_download`
(app.py:2650-2653; views/database.py:533-570).
- **Permission:** `view-database-download` (denied → `Forbidden` → 403 HTML).
- **Other gates:** unknown database → 404 `"Invalid database"`; in-memory
database → 404; `allow_download` off **or** mutable database →
`Forbidden("Database download is forbidden")`; no file path → 404.
- **Response:** streamed `application/octet-stream` with a
`content-disposition` attachment; immutable databases with a known hash set
`Etag` and honor `If-None-Match` → 304.
### GET /\<database\>(.json)
`DatabaseView` (app.py:2654-2657; views/database.py:71-277). Only `html` and
`json` formats are accepted; any other extension → 404 `"Invalid format: ..."`.
- **Permission:** `view-database` via `check_visibility` (denied →
`Forbidden` → 403 HTML). Table/view listings are filtered by `view-table`;
stored queries by `view-query`.
- **Parameters:**
- `?sql=` — non-blank value 302-redirects to `/<database>/-/query?...`
preserving the query string and format.
- No `?_extra=` and no `_shape` support — the JSON is built directly and
returned via `Response.json`, bypassing the JSON renderer
(views/database.py:189-212).
- **JSON response** (all keys always present):
- `ok` — always `true`
- `database` — name; `private` — bool; `path` — URL path; `size` — bytes
- `tables` — list (includes hidden tables), each:
`name`, `columns` (names), `primary_keys`, `count` (int or null,
time-boxed), `count_truncated` (bool — count is a capped lower bound),
`hidden`, `fts_table`, `foreign_keys` (`{incoming: [...], outgoing: [...]}`
of `{other_table, column, other_column}`), `private`
- `hidden_count` — number of hidden tables
- `views` — list of `{name, private}`
- `queries` — **up to 5** stored queries (canonical stored-query objects,
see the stored-queries section); `queries_more` (bool);
`queries_count` (total visible)
- `allow_execute_sql` — bool for this actor
- `table_columns` — `{table: [columns]}`, empty `{}` unless
`allow_execute_sql` (views map to `[]`)
- `metadata` — database metadata dict
### GET /\<database\>/-/query(.json) — arbitrary SQL
`QueryView` (app.py:2691-2694; views/database.py:573-1130). The same class
also executes stored queries dispatched from the table route (see stored
queries section).
- **Permission:** `execute-sql` on the database via `check_visibility`
(denied → `Forbidden` → 403 HTML).
- **Parameters:**
- `sql` — SQL to run. Must pass `validate_sql_select`
(utils/__init__.py:345-354): after stripping `--` comment lines it must
start with `select`, `with` or an `explain` variant, and must not contain
`pragma` (except allowlisted `pragma_*()` table-valued functions).
Failure → 400 `DatasetteError` titled `"Invalid SQL"` → JSON
`{"ok": false, "error": "Statement must be a SELECT", "status": 400,
"title": "Invalid SQL"}`.
- Any other `name=value` pair supplies the `:name` named parameter; missing
parameters default to `""`. Names starting with `_` are excluded.
- `_timelimit` — per-request SQL time limit in ms.
- `_shape`, `_nl`, `_json`, `_json_infinity` — see the JSON renderer section.
- `_extra` — QUERY-scope extras: `columns`, `debug`, `request`,
`render_cell`, `query` (`{"sql", "params"}`), `metadata`, `database`,
`database_color`, `private`, `extras`.
- **Response** (default shape):
`{"ok": true, "rows": [{col: val}, ...], "truncated": false}` plus any
requested extras. `truncated: true` when the result hit `max_returned_rows`.
- **Errors:**
- SQLite errors (e.g. `no such table`) are **not** raised — they surface as
HTTP 400 `{"ok": false, "error": "<message>", "rows": [], "truncated": false}`.
- Time limit → 400 titled `"SQL Interrupted"` (the `error` value contains
an HTML fragment).
- `?sql=` omitted → 200 `{"ok": true, "rows": [], "truncated": false}`
(the CSV format instead errors 400 `"?sql= is required"`).
- `.csv` streams CSV; unknown extensions → 404.
### GET /\<database\>/-/query/parameters
`QueryParametersView` (app.py:2687-2690; views/stored_queries.py:26-51).
- **Permission:** `execute-sql` → 403 JSON
`{"ok": false, "errors": ["Permission denied: need execute-sql"]}`.
- **Parameters:** only `sql` (default `""`); any other key → 400
`"Invalid keys: ..."`.
- **Response:** 200 `{"ok": true, "parameters": ["name1", ...]}`. SQL with a
parameter beginning `_` → 400 `"Magic parameters are not allowed"`.
- Responses carry `Content-Security-Policy: frame-ancestors 'none'` and
`X-Frame-Options: DENY`.
### POST /\<database\>/-/create
`TableCreateView` (app.py:2658; views/table_create_alter.py:785-962).
GET → 405. Body is parsed as JSON regardless of content type; invalid JSON →
400 `{"ok": false, "errors": ["Invalid JSON: ..."]}`.
- **Permissions** (all denials → 403 canonical error JSON,
all checked at the **database** level):
- `create-table` — always required (`["Permission denied"]`)
- `insert-row` — if `rows`/`row` provided (`need insert-row`)
- `update-row` — if `replace: true` (`need update-row`)
- `alter-table` — if `alter: true` on an **existing** table
(`need alter-table`); when the table does not exist yet and rows are
supplied, alter is enabled automatically.
- **Request schema** (pydantic `CreateTableRequest`, extra keys forbidden →
400 `"Invalid keys: a, b"`):
- `table` (required) — must match `^(?!sqlite_)[^\n]+$`
- `rows` (list of objects) / `row` (single object) — mutually exclusive
- `columns` — list of `{name, type, fk_table, fk_column, not_null,
default, default_expr}`; mutually exclusive with `rows`/`row`; `type` one
of `text`/`integer`/`float`/`blob` (default `text`); `default` and
`default_expr` mutually exclusive; `default_expr` one of
`current_timestamp`, `current_date`, `current_time`, `current_unixtime`,
`current_unixtime_ms`. At least one of `columns`/`rows`/`row` required.
- `pk` (string) / `pks` (list) — mutually exclusive. For an existing table
a differing pk → 400 `"pk cannot be changed for existing table"`.
- `ignore` / `replace` (bools) — mutually exclusive; require `row`/`rows`
and `pk`/`pks`.
- `alter` (bool) — add missing columns when inserting into an existing table.
- **Success** — **201**:
```json
{"ok": true, "database": "...", "table": "...",
"table_url": "https://.../db/table", "table_api_url": "https://.../db/table.json",
"schema": "CREATE TABLE ...", "row_count": 2}
```
`row_count` only when rows were inserted. Write failures → 400
`{"ok": false, "errors": ["<sqlite message>"]}`. Emits `create-table` /
`insert-rows` / `alter-table` events.
### POST /\<database\>/-/execute-write
`ExecuteWriteView` (app.py:2679-2682; views/execute_write.py:236-476). GET on
the same path renders an HTML form (requires `execute-write-sql`).
- **Permission (POST):** `execute-write-sql` → 403
`{"ok": false, "errors": ["Permission denied: need execute-write-sql"]}`;
immutable database → 403 `["Database is immutable"]`.
- **Per-statement permissions:** the SQL is analyzed
(`decision_for_write_sql_operation`, write_sql.py:63-189) and each
operation must pass:
| Operation | Requirement |
|---|---|
| `select` / internal ops / function calls | ignored |
| read of a table | `view-table` on that table |
| `insert` or `update` | **all of** `insert-row`, `update-row`, `delete-row` on the table |
| `delete` | `delete-row` |
| `create table` | `create-table` on the database |
| `alter table`, `create index`, `drop index` | `alter-table` on the table |
| `drop table` | `drop-table` |
| `VACUUM`, virtual-table writes, shadow-table writes | rejected outright (403) |
| statements touching attached databases | rejected (403) |
- **Body:** JSON (`{"sql": ..., "params": {...}}` — only those two keys) or
form-encoded (`sql` plus one field per parameter, `_sql_param_` prefix
stripped). Validation errors (400): `"SQL is required"`,
`"params must be a dictionary"`, `"Unknown parameters: a, b"`,
`"Magic parameters are not allowed"`, `"Could not analyze query: ..."`,
`"Use /-/query for read-only SQL; this endpoint only executes writes"`.
- **JSON is returned when** the body was JSON, `Accept: application/json`, or
a truthy `_json` field is present; otherwise HTML.
- **Success** — 200:
```json
{"ok": true, "message": "Query executed, 1 row affected", "rowcount": 1,
"rows": [], "truncated": false,
"analysis": [{"operation": "insert", "database": "db", "table": "t",
"required_permission": "insert-row, update-row, delete-row",
"source": null}]}
```
`rows` is populated by `RETURNING` clauses. SQLite errors → 400
`{"ok": false, "errors": ["<message>"]}`. Anti-framing headers on all
responses.
### GET /\<database\>/-/execute-write/analyze
`ExecuteWriteAnalyzeView` (app.py:2675-2678; views/execute_write.py:479-507).
- **Permission:** `execute-write-sql` → 403 `errors` JSON.
- **Parameters:** only `sql` allowed (else 400 `"Invalid keys: ..."`).
- **Response** — 200 even when analysis fails (`ok: false` in body):
`{"ok", "parameters", "analysis_error", "analysis_rows":
[{operation, database, table, required_permission, source, allowed}],
"execute_disabled", "execute_disabled_reason"}`. `allowed` is a per-actor
permission check result (true/false/null).
### GET /\<database\>/-/foreign-key-targets
`DatabaseForeignKeyTargetsView` (app.py:2659-2662;
views/table_create_alter.py:965-1005).
- **Parameter:** `table` (optional) — only used for the permission check.
- **Permission:** `create-table` on the database, **or** `alter-table` on
`?table=` when it names an existing table. Neither → 403
`{"ok": false, "errors": ["Permission denied: need create-table"]}`.
- **Response:** 200 `{"ok": true, "database": "...", "targets":
[{"fk_table", "fk_column", "type"}]}` — every non-hidden table with exactly
one primary-key column; `type` is the pk's SQLite type affinity.
### GET /\<database\>/-/schema(.json|.md)
`DatabaseSchemaView` (app.py:2683-2686; views/special.py:1296-1329).
- **Permission:** `view-database` (denied → `Forbidden` → 403 HTML).
- **Unknown database** → 404; for `.json`:
`{"ok": false, "error": "Database not found"}`. (The existence check runs
before the permission check.)
- **Responses:** `.json` → 200 `{"ok": true, "database": "<name>", "schema": "<SQL>"}`
(concatenated `sqlite_master.sql` joined with `;\n`); `.md` →
`text/markdown`; no extension → HTML.
---
## Table and row read endpoints
### GET /\<database\>/\<table\>.json
Route `r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$"` →
`table_view` (app.py:2711-2714; views/table.py:1670). Serves both tables and
SQL views. GET/HEAD only — POST returns a plain-text 405. If the name is
neither a table nor a view but matches a stored query, the request is
dispatched to `QueryView` (views/table.py:1703-1712).
**Permission:** `view-table` via `check_visibility`; denial raises
`Forbidden` → **HTML** 403 page even for `.json`. Unknown table →
`TableNotFound` → 404 (JSON error shape for `.json` paths).
**Default JSON keys** (views/table.py:2308-2332 + renderer):
| Key | Meaning |
|---|---|
| `ok` | `true` when data was retrieved without error |
| `next` | pagination token string, or `null` on the last page |
| `rows` | list of row objects `{column: value}` (default `_shape=objects`) |
| `truncated` | always present; `false` for table pages |
`columns` is computed but removed unless `?_extra=columns` was requested.
When there is a next page the response carries a
`Link: <next_url>; rel="next"` header (views/table.py:1911-1912).
**`?_extra=` options** (TABLE scope; registry
views/table_extras.py:1197-1235; unknown names silently ignored):
| `_extra=` | Returns |
|---|---|
| `count` | total matching-row count, computed with a `limit 10001` subquery so it caps at 10001; `null` with `_nocount` or on count timeout |
| `count_sql` | the SQL used for the count |
| `facet_results` | `{"results": {name: facet}, "timed_out": [...]}`; each facet: `{name, type, hideable, toggle_url, results: [{value, label, count, toggle_url, selected}], truncated}` |
| `facets_timed_out` | facet names that exceeded `facet_time_limit_ms` |
| `suggested_facets` | `[{name, toggle_url, (type)}]`; empty when suggestion is disabled or paginating |
| `human_description_en` | English description of filters + sort |
| `next_url` | absolute URL of the next page or `null` |
| `columns` | column names of the returned rows |
| `all_columns` | all table columns regardless of `_col`/`_nocol` |
| `primary_keys` | pk column names (empty for rowid tables and views) |
| `display_columns` | HTML-oriented column metadata |
| `render_cell` | per-row plugin-rendered HTML strings |
| `debug` | `{url_vars, resolved, nofacet, nosuggest}` — explicitly unstable |
| `request` | `{url, path, full_path, host, args}` |
| `query` | `{sql, params}` of the main query |
| `column_types` | `{column: {type, config}}` assigned column types |
| `set_column_type_ui` | UI helper, `null` unless actor has `set-column-type` |
| `metadata` | table metadata dict including column descriptions |
| `extras` | self-describing list of all available extras |
| `database`, `table`, `database_color` | identity/display values |
| `renderers` | `{format_name: url}` of formats that can render this data |
| `custom_table_templates` | template lookup list |
| `sorted_facet_results` | facets as a display-ordered list |
| `table_definition` | `CREATE TABLE` SQL |
| `view_definition` | `CREATE VIEW` SQL, `null` for tables |
| `is_view` | boolean |
| `private` | `true` if visible to this actor but not anonymously |
| `expandable_columns` | `[[foreign_key, label_column_or_null], ...]` |
| `form_hidden_args` | pairs of `_`-prefixed args for HTML forms |
Non-public extras (`actions`, `filters`, `display_rows`) are HTML-only and
never appear in JSON. `_extra=_html` expands to the full HTML bundle
(views/table_extras.py:1162-1194). Any `_facet*` argument implicitly adds
`facet_results`; `_shape=object` implicitly adds `primary_keys`
(views/table.py:2252-2256). There is **no** `filtered_table_rows_count`
extra — it was replaced by `count`.
**Column filters `?<column>__<op>=<value>`** (filters.py:260-427). Any
querystring key not starting with `_` is a filter; bare `?column=value` means
`exact`. Columns whose names start with `_` can be filtered as
`?_col__exact=`. Operators:
| op | SQL |
|---|---|
| `exact` | `"col" = :p` (default) |
| `not` | `"col" != :p` |
| `contains` / `notcontains` | `like '%v%'` / `not like '%v%'` |
| `endswith` / `startswith` | `like '%v'` / `like 'v%'` |
| `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` (numeric strings cast to int) |
| `like` / `notlike` | raw `like` / `not like` pattern |
| `glob` | `glob` |
| `in` / `notin` | comma-separated list, or JSON array if the value starts with `[` |
| `arraycontains` / `arraynotcontains` | `[not] in (select value from json_each("col"))` (requires JSON1) |
| `date` | `date("col") = :p` |
| `isnull` / `notnull` | `is null` / `is not null` (no value) |
| `isblank` / `notblank` | `(is null or = '')` / opposite (no value) |
**Special (underscore) parameters:**
| Param | Behavior |
|---|---|
| `_where=SQL` | extra raw where clause (repeatable); requires `execute-sql` else 403 `"_where= is not allowed"` |
| `_search=q` | FTS against the table's FTS table |
| `_search_<column>=q` | FTS restricted to one column; 400 if invalid |
| `_searchmode=raw` | pass the query straight to `match` |
| `_fts_table=` / `_fts_pk=` | override the FTS table / pk used for joins |
| `_through={"table","column","value"}` | filter via an incoming foreign key (repeatable, JSON value) |
| `_sort=col` / `_sort_desc=col` | sort; 400 if both given or column not sortable |
| `_next=token` | pagination token |
| `_size=N\|max` | page size; default `default_page_size` (100); `max` = `max_returned_rows` (1000); 400 on invalid |
| `_col=name` (repeatable) | return only pks + these columns; 400 on invalid |
| `_nocol=name` (repeatable) | exclude columns; 400 if invalid or a pk |
| `_labels=on` | expand every FK column into `{"value", "label"}` |
| `_label=col` (repeatable) | expand only the named FK column(s) |
| `_facet=col` | request a facet; 400 `"_facet= is not allowed"` when `allow_facet` off |
| `_facet_array=col` / `_facet_date=col` | typed facets |
| `_facet_size=N\|max` | facet bucket count, default 30, capped at `max_returned_rows` |
| `_nocount=1` | skip count (`count` extra → null) |
| `_nofacet=1` | skip facets and suggestions |
| `_nosuggest=1` | skip facet suggestions only |
| `_shape=` | see renderer section; `array`/`object` also force `_nocount` and `_nofacet` |
| `_nl=on` | NDJSON with `_shape=array` |
| `_json=col` / `_json_infinity=1` | renderer options |
| `_timelimit=ms` | custom SQL time limit |
| `_ttl=seconds` | `Cache-Control: max-age=N` (`0` → `no-cache`); default `default_cache_ttl` (5) |
| `_trace=1` | append `_trace` key (requires `trace_debug` setting) |
| `_extra=` | see above |
**Pagination** is keyset-based for tables: `page_size + 1` rows are fetched;
`next` is built from the last row of the page — comma-joined tilde-encoded
primary-key values, prefixed by the sort value when sorted (`$null` for null
sort values) (views/table.py:2041-2111, 2421-2482). `next_url` is the
absolute URL with `_next` replaced.
### GET /\<database\>/\<view_name\>.json (SQL views)
Same code path with `is_view=True`. Differences:
- No primary keys: `primary_keys` → `[]`; `_shape=object` fails; base query
has no `order by`.
- **Pagination is offset-based**: `_next` is an integer offset applied as
`limit N offset M` (views/table.py:2047-2049, 2438-2439) — unlike the
keyset tokens used for tables.
- `view_definition` returns the `CREATE VIEW` SQL; `table_definition` is null.
### GET /\<database\>/\<table\>/\<pks\>.json
`RowView` (app.py:2715-2718; views/row.py:137). `<pks>` is comma-separated
tilde-encoded primary key values (rowid for rowid tables).
- **Permission:** `view-table` (denied → `Forbidden` → 403 HTML). Missing row
→ 404 `"Record not found: [...]"`.
- **Default JSON keys:** `ok`, `database`, `table`, `rows` (single-element
list), `primary_keys`, `primary_key_values`, `query_ms`,
`truncated: false`; `columns` only with `?_extra=columns`.
- **`?_extra=` (ROW scope):** `columns`, `primary_keys`, `render_cell`,
`debug`, `request`, `query`, `column_types`, `metadata`, `extras`,
`database`, `table`, `database_color`, `private`, `foreign_key_tables`
(incoming FKs with `count` and `link`; single-pk rows only).
- **Foreign-key label expansion does not apply to row JSON** — `_labels` has
no effect here; expansion happens only in the HTML path
(views/row.py:445-475).
- `_shape`, `_json`, `_nl`, `_json_infinity`, `_ttl` apply.
### The .blob format
`/<database>/<table>/<pks>.blob?_blob_column=col` (also on query pages) —
fetches raw binary bytes (blob_renderer.py:10-61). `_blob_column` required
(400 if missing/invalid); optional `_blob_hash` must equal the value's
SHA-256 (else 400 `"Link has expired..."`). Returns `application/binary` as a
download attachment. In JSON output, binary cells appear as
`{"$base64": true, "encoded": "..."}`.
### GET /\<database\>/\<table\>/-/schema(.json|.md)
`TableSchemaView` (app.py:2751-2754; views/special.py:1332-1378).
- **Permission:** `view-table` via `ensure_permission` (denied → 403 HTML).
- **Responses:** `.json` → 200 `{"ok": true, "database", "table", "schema"}`;
`.md` → `text/markdown`; no extension → HTML. Missing table → 404
`{"ok": false, "error": "Table not found"}` for `.json`.
### GET /\<database\>/\<table\>/-/fragment
`TableFragmentView` (app.py:2739-2742; views/table.py:1385-1418).
**HTML-only** — returns the `_table.html` partial; no JSON variant. Accepts
table querystring parameters plus `_row=<pk-path>` to render a single row.
### GET /\<database\>/\<table\>/-/autocomplete
`TableAutocompleteView` (app.py:2743-2746; views/table.py:1492-1595). Tables
only — views get 400 `"Autocomplete is only available for tables"`.
- **Permission:** `view-table` (denied → `Forbidden` → 403).
- **Parameters:** `q` (matched with escaped `LIKE %q%` against pk columns and
the label column) and `_initial` (truthy: with empty `q`, return the 10
most recent rows). Neither → `{"ok": true, "rows": []}`.
- **Response:** `{"rows": [{"pks": {pk_name: value}, "label": "..."}]}` — max
10 items; 500 ms query budget with fallbacks, timing out to
`{"ok": true, "rows": []}`.
---
## The write API
All write endpoints return errors via `_error()` (the canonical error
shape) and check permissions with
`datasette.allowed()` directly, so their 403s are JSON (unlike the
`Forbidden`-raising read endpoints). Routes: app.py:2719-2762.
### POST /\<database\>/\<table\>/-/insert
`TableInsertView` (views/table.py:907-1194).
- **Permissions:** `insert-row` on the table (denied → 403
`["Permission denied"]`); `update-row` additionally required for
`replace: true` (403 `need update-row to use "replace"`); `alter-table`
additionally required for `alter: true` (403
`Permission denied for alter-table`). Immutable database → 403
`Database is immutable`.
- **Request** — requires `Content-Type: application/json` (else 400
`"Invalid content-type, must be application/json"`). Body:
| Field | Rules |
|---|---|
| `row` | single object; mutually exclusive with `rows`; forces `return: true` |
| `rows` | list of objects; max `max_insert_rows` (default 100), else 400 `"Too many rows, maximum allowed is 100"` |
| `ignore` | skip rows whose pk already exists; mutually exclusive with `replace` |
| `replace` | replace rows with matching pks (needs `update-row`) |
| `alter` | add missing columns (needs `alter-table`) |
| `return` | include inserted rows in the response |
One of `row`/`rows` required. Unknown keys → 400 `"Invalid parameter: ..."`.
Unless `alter`, row keys must be existing columns → per-row 400
`"Row 0 has invalid columns: x, y"`. Values are validated against assigned
column types.
- **Response** — **201** `{"ok": true}`; with `return: true` also `rows`
(the rows as stored, re-fetched by rowid). SQLite errors during the write →
400 with the message. Emits `insert-rows` (and possibly `alter-table`)
events.
### POST /\<database\>/\<table\>/-/upsert
`TableUpsertView` — subclasses insert (views/table.py:1197-1201).
- **Permissions:** **both** `insert-row` and `update-row` (403
`need both insert-row and update-row`); `alter: true` needs `alter-table`.
- **Request:** same as insert, except `ignore`/`replace` are rejected (400
`"Upsert does not support ignore or replace"`) and **every row must contain
the table's primary key(s)** (per-row 400
`Row 0 is missing primary key column(s): "id"` / `has null primary key`).
- **Response** — **200** (note: insert returns 201) `{"ok": true}`; with
`return: true`, `rows` re-fetched by pk. Emits `upsert-rows`.
### POST /\<database\>/\<table\>/-/alter
`TableAlterView` (views/table_create_alter.py:1130-1353).
- **Permission:** `alter-table` (403 `need alter-table`); immutable → 403.
- **Request:** `{"operations": [{"op": ..., "args": {...}}, ...]}` — a
non-empty list, validated by pydantic (extra keys forbidden anywhere;
errors → 400 `location: message`):
| `op` | `args` |
|---|---|
| `add_column` | `name` (required), `type` (`text`/`integer`/`float`/`blob`, default `text`), `not_null`, `default` xor `default_expr`; `not_null: true` requires a default |
| `rename_column` | `name`, `to` |
| `rename_table` | `to` (must not start `sqlite_`) |
| `alter_column` | `name` + at least one of `type`, `not_null`, `default`, `default_expr` |
| `drop_column` | `name` |
| `set_primary_key` | `columns` (non-empty list) |
| `reorder_columns` | `columns` (non-empty list) |
| `add_foreign_key` | `column`, `fk_table`, optional `fk_column` |
| `drop_foreign_key` | `column` |
| `set_foreign_keys` | `foreign_keys`: list of `{column, fk_table, fk_column?}` |
`default_expr` must be one of the five `current_*` keywords. Operations are
applied in a single write transaction; any failure → 400.
- **Response** — 200:
```json
{"ok": true, "database": "...", "table": "<possibly renamed>",
"table_url": "...", "table_api_url": "...",
"altered": true, "schema": "...", "before_schema": "...",
"operations_applied": 2}
```
### POST /\<database\>/\<table\>/-/drop
`TableDropView` (views/table.py:1320-1382).
- **Permission:** `drop-table` (403 `Permission denied`); immutable → 403.
- **Confirmation flow:** without `{"confirm": true}` in the body, nothing is
dropped and a 200 preview is returned:
`{"ok": true, "database", "table", "row_count",
"message": "Pass \"confirm\": true to confirm"}`. With `confirm: true` →
200 `{"ok": true}`. Emits `drop-table`.
### POST /\<database\>/\<table\>/-/set-column-type
`TableSetColumnTypeView` (views/table.py:1204-1317). Assigns a Datasette
*column type* (metadata stored in the internal `column_types` table) — it
does not change the SQLite schema.
- **Permission:** `set-column-type` (403 `Permission denied`).
- **Request** (JSON content type required): `{"column": "name",
"column_type": {"type": "url", "config": {...}?} | null}`. Unknown
keys/invalid structure → detailed 400 errors; unknown type → 400
`"Unknown column type: x"`. Default registered types (via the
`register_column_types` hook): `url`, `email`, `json`, `textarea`.
- **Response** — 200 `{"ok": true, "database", "table", "column",
"column_type": {...} | null}`.
### GET /\<database\>/\<table\>/-/foreign-key-suggestions
`TableForeignKeySuggestionsView` (views/table_create_alter.py:1008-1127).
**GET only** (read-only despite living beside the write endpoints).
- **Permission:** `alter-table` (403 `need alter-table`); views → 400
`"Cannot suggest foreign keys for a view"`.
- **Response** — 200: `{"ok": true, "database", "table",
"row_check": {attempted, status, row_limit, sampled_rows, checked_options},
"columns": [{column, type, affinity, current,
"suggestions": [{fk_table, fk_column, confidence, sampled_values, reasons}],
"options": [...]}]}`. Samples up to 500 rows within 50 ms/200 ms budgets.
### POST /\<database\>/\<table\>/\<pks\>/-/update
`RowUpdateView` (views/row.py:781-870).
- **Permissions:** `update-row` (403 `Permission denied`); `alter: true`
additionally requires `alter-table` (403
`Permission denied for alter-table`).
- **404s:** `Database not found: x` / `Table not found: x` /
`Record not found: [pks]`.
- **Request:** `{"update": {column: value, ...}, "return"?: true,
"alter"?: true}`. Missing/non-dict `update` → 400
`"JSON must contain an update dictionary"`; unknown keys → 400
`"Invalid keys: ..."`; write failures (bad column, constraint violation) →
400 with the message.
- **Response** — 200 `{"ok": true}`; with `return: true`,
`{"ok": true, "row": {...}}` (singular `row`, unlike insert/upsert's
`rows`). Emits `update-row`.
### POST /\<database\>/\<table\>/\<pks\>/-/delete
`RowDeleteView` (views/row.py:738-778).
- **Permission:** `delete-row` (403 `Permission denied`). 404s as update.
- **Request:** no body required (any body is ignored — there is no
confirmation step, unlike table drop).
- **Response** — 200 `{"ok": true}`; with `?_redirect_to_table` a `redirect`
key is added. A failure during the write returns **500** with the message
(unlike update's 400). Emits `delete-row`.
---
## Stored (canned) queries API
Stored queries live in the internal database's `queries` table
(utils/internal_db.py:116-133). Queries defined in `datasette.yaml` are
synced in at startup with `source="config"` and `is_trusted` defaulting to
true; queries created via the API get `source="user"`, `is_trusted=false`,
`owner_id` = actor id.
**Canonical stored-query JSON object** (`stored_query_to_dict`,
stored_queries.py:55-80):
```json
{
"database": "...", "name": "...", "sql": "...",
"title": null, "description": null, "description_html": null,
"hide_sql": false, "fragment": null,
"params": ["p"], "parameters": ["p"],
"is_write": false, "is_private": true, "is_trusted": false,
"source": "user", "owner_id": "...",
"on_success_message": null, "on_success_message_sql": null,
"on_success_redirect": null,
"on_error_message": null, "on_error_redirect": null,
"private": true
}
```
`params` and `parameters` are identical lists, both always present.
`private` appears only in list responses.
**Default permission rules for queries** (default_permissions/defaults.py):
`view-query` is default-allow, but private queries are visible only to their
owner; the owner may `update-query`/`delete-query` their `source='user'`
queries.
### GET /-/queries(.json) and GET /\<database\>/-/queries(.json)
`GlobalQueryListView` / `QueryListView` (app.py:2606-2609, 2663-2666;
views/stored_queries.py:69-238). The global variant lists queries across all
databases (`database`/`database_color` are null, `show_database` true).
- **Permissions:** no single gate; results filtered per query by
`view-query` (private queries appear only for their owner).
- **Parameters:** `_size` (default 20 HTML / **50 JSON**, clamped 11000;
non-integer → 400), `_next` (cursor), `q` (substring search over
name/title/description/sql), `is_write` / `is_private` (booleans; invalid →
400 `"is_write must be 0 or 1"`), `source`, `owner_id`.
- **Response** — 200:
`{"ok": true, "database", "database_color", "queries": [...], "next",
"next_url", "has_more", "limit", "show_private_note",
"show_trusted_note", "query_list_path", "show_database",
"facets": [{title, items: [{label, count, href, active}]}],
"filters": {q, is_write, is_private, source, owner_id}}`.
### GET /\<database\>/-/queries/analyze
`QueryCreateAnalyzeView` (app.py:2667-2670; views/stored_queries.py:290-322).
**GET only** despite being an "analyze" action — POST → 405.
- **Permissions:** `execute-sql` then `store-query` (each denial → 403
`errors` JSON).
- **Parameters:** only `sql` (others → 400 `"Invalid keys: ..."`).
- **Response** — 200: `{"ok", "parameters", "analysis_error",
"analysis_rows": [{operation, database, table, required_permission,
source, allowed}], "has_sql", "analysis_is_write", "save_disabled"}`.
### POST /\<database\>/-/queries/store
`QueryStoreView` (app.py:2671-2674; views/stored_queries.py:325-388). GET on
the same path renders the HTML create form.
- **Permissions:** `execute-sql` + `store-query` (403 `errors` JSON).
- **Request:** JSON bodies must wrap the fields:
`{"query": {...fields...}}`; form bodies pass fields flat. Fields:
`name` (required; `^[^/\.\n]+$`; conflicts with tables/views or existing
queries → 400), `sql` (required; read SQL must pass `validate_sql_select`;
write SQL must pass per-operation permission checks), `title`,
`description`, `hide_sql`, `fragment`, `parameters`/`params` (must exactly
match the SQL's named parameters; magic parameters rejected),
`is_private` (**default true**), and — only for write SQL —
`on_success_message`, `on_success_redirect`, `on_error_message`,
`on_error_redirect`. `is_write` is derived from SQL analysis;
`is_trusted`, `description_html` and `on_success_message_sql` cannot be
set through this API.
- **Response:** JSON request → **201** `{"ok": true, "query": {...}}`; form
request → 302 redirect.
### GET /\<database\>/\<query\>/-/definition
`QueryDefinitionView` (app.py:2695-2698; views/stored_queries.py:391-408).
- **Permission:** `view-query` (403 `["Permission denied"]`).
- **Response:** 200 `{"ok": true, "query": {...}}`; 404
`["Query not found: x"]`.
### GET/POST /\<database\>/\<query\>/-/edit
`QueryEditView` (app.py:2699-2702) — **HTML form endpoint**
(`has_json_alternate = False`), not part of the JSON API. Programmatic
updates use `/-/update`.
### POST /\<database\>/\<query\>/-/update
`QueryUpdateView` (app.py:2703-2706; views/stored_queries.py:411-465).
- **Permissions:** `update-query` (403 `need update-query`); trusted queries
→ 403 `"Trusted queries cannot be updated using the API"`; changing `sql`
additionally requires `execute-sql`.
- **Request:** `{"update": {...partial fields...}, "return"?: true}` — other
top-level keys → 400. Updatable fields: `sql`, `title`, `description`,
`hide_sql`, `fragment`, `parameters`/`params`, `is_private`, `on_*`
fields (write SQL only). New SQL is re-analyzed and `is_write` recomputed.
- **Response:** 200 `{"ok": true}` (plus `query` with `return: true`); 404
`"Query not found: x"`.
### POST /\<database\>/\<query\>/-/delete
`QueryDeleteView` (app.py:2707-2710; views/stored_queries.py:594-644). GET
renders an HTML confirmation page.
- **Permission:** `delete-query` (403 `need delete-query`). Unlike update,
**trusted queries are not blocked** from API deletion.
- **Response:** JSON request → 200 `{"ok": true}`; form → 302; 404
`"Query not found: x"`. No `confirm` field required (unlike table drop).
### GET/POST /\<database\>/\<query-name\>(.json) — executing a stored query
No dedicated route: the table route resolves the name, and on `TableNotFound`
the request is dispatched to `QueryView` when a stored query matches
(views/table.py:1698-1712). Covers both config-defined and API-stored
queries.
**GET (read queries)** — `QueryView.get` (views/database.py:695-1130):
- **Permissions:** `view-query` (denied → `Forbidden` → 403 HTML). Read
queries then require `execute-sql` unless `is_trusted`. Write queries are
**not executed** on GET — JSON returns empty `rows`; HTML shows a POST form.
- **Parameters:** each named `:param` is read from the query string (missing
→ `""`); `_timelimit`; renderer options (`_shape`, `_nl`, `_json`,
`_json_infinity`); `_extra` (QUERY scope).
- **Response:** `{"ok": true, "rows": [...], "truncated": false}` + extras.
SQL errors → 400 with `error` in the envelope.
**POST (write queries)** — `QueryView.post` (views/database.py:574-693):
- **Permissions:** `view-query`; then, unless `is_trusted`:
`execute-write-sql` on the database **plus** per-operation write
permissions (same table as `/-/execute-write`). Rejection → 403
`{"ok": false, "message": "...", "redirect": null}` for JSON clients.
Immutable database → 403.
- **Body:** form-encoded or JSON `param=value` pairs (values coerced to
strings).
- **JSON is returned when** `Accept: application/json`, `?_json=1`, or a
`_json` body field is present; otherwise 302 + flash message.
- **Magic parameters** (`:_<prefix>_<key>`, resolved server-side; registered
via `register_magic_parameters`, default_magic_parameters.py):
`_now_epoch`, `_now_date_utc`, `_now_datetime_utc`, `_actor_<key>`,
`_random_chars_<N>`, `_cookie_<name>`, `_header_<name>` (underscores →
hyphens). User-stored queries cannot contain magic parameters — they are a
feature of config/trusted queries.
- **Response:** success → 200
`{"ok": true, "message": "...", "redirect": "..."|null}` — `message`
honors `on_success_message_sql` / `on_success_message`, falling back to
`"Query executed"` or `"Query executed, N rows affected"`. SQL failure →
**400** canonical error (message honors `on_error_message`) plus a
`redirect` context key from `on_error_redirect`. Operation rejection
(`QueryWriteRejected`, e.g. VACUUM) → 403 canonical error plus
`redirect: null`.
---
## Authentication and tokens
### Bearer tokens (`dstok_`)
Signed API tokens are sent as `Authorization: Bearer dstok_...`. The
`actor_from_signed_api_token` hook (default_permissions/tokens.py:25-40)
passes the token to `datasette.verify_token()`, which tries every handler
registered via `register_token_handler`; the default is
`SignedTokenHandler` (tokens.py:117-193).
- **Format:** `dstok_` + itsdangerous-signed payload (namespace `token`)
containing `a` (actor id), `t` (creation Unix time), optional `d`
(duration seconds), optional `_r` (restrictions).
- **Verification** returns no actor when: `allow_signed_tokens` is off, the
signature is invalid, `t` is missing/non-integer, or the token is expired.
The effective duration is `d` capped by `max_signed_tokens_ttl` (default 0
= no cap; a non-zero setting also imposes a TTL on tokens without `d`).
- **Resulting actor:** `{"id": <a>, "token": "dstok"}` plus `"_r"` and
`"token_expires"` when applicable. Invalid/expired tokens silently produce
an anonymous request (no 401) — the failure then surfaces as a 403 from
whatever permission check the request hits.
**Restrictions (`_r`)** (default_permissions/restrictions.py):
- `"a"`: list of actions allowed on any resource
- `"d"`: `{database_name: [actions]}`
- `"r"`: `{database_name: {table_name: [actions]}}`
Actions are stored as abbreviations when available (see appendix); checks
accept either the full name or the abbreviation. Restrictions are an
allowlist filter layered on top of normal permission resolution — a
restricted token can never do more than its allowlist, and never more than
the underlying actor could do anyway.
### Token creation
- **`/-/create-token`** is an HTML form endpoint only (see the instance
section) — there is no JSON API to mint tokens in this codebase.
- Programmatic alternatives: the `datasette create-token` CLI command and
the `datasette.create_token()` Python API.
- `/-/auth-token` is the one-time `--root` login mechanism, unrelated to API
tokens.
### Cookie authentication
Browser sessions use the signed `ds_actor` cookie (set by `/-/auth-token`,
plugins, or login flows; cleared by `/-/logout`). API POSTs from browsers are
subject to the cross-origin checks described in
[CSRF](#csrf--cross-origin-protection).
---
## Appendix: registered actions (permissions)
From `datasette/default_actions.py` (registered via the `register_actions`
hook). Token restrictions store the abbreviation when available.
| Action | Abbr | Resource level | Notes |
|---|---|---|---|
| `view-instance` | `vi` | global | |
| `permissions-debug` | `pd` | global | gates the debug endpoints |
| `debug-menu` | `dm` | global | UI only |
| `view-database` | `vd` | database | |
| `view-database-download` | `vdd` | database | `also_requires="view-database"` |
| `execute-sql` | `es` | database | `also_requires="view-database"`; denied when the `default_allow_sql` setting is off |
| `execute-write-sql` | `ews` | database | `also_requires="view-database"` |
| `create-table` | `ct` | database | |
| `store-query` | `sq` | database | `also_requires="execute-sql"` |
| `view-table` | `vt` | table | |
| `insert-row` | `ir` | table | |
| `delete-row` | `dr` | table | |
| `update-row` | `ur` | table | |
| `alter-table` | `at` | table | |
| `set-column-type` | `sct` | table | |
| `drop-table` | `dt` | table | |
| `view-query` | `vq` | query | default-allow; private queries restricted to their owner |
| `update-query` | `uq` | query | query owner allowed by default (source=`user` only) |
| `delete-query` | `dq` | query | query owner allowed by default (source=`user` only) |