datasette/existing-api.md
Claude 3a0ea58557
Unify page-size parameters on _size with table semantics
The stored query lists silently clamped out-of-range ?_size= values
(a request for 5000 quietly returned 1000) and did not accept the max
keyword. They now share the table view semantics via a new
parse_size_limit() helper: blank means default, "max" means the
maximum (max_returned_rows for query lists), negative or non-integer
values are a 400, and values over the maximum are a 400 instead of
being silently clamped.

The /-/allowed and /-/rules debug endpoints renamed their bare
page/page_size parameters to _page/_size, matching the underscore
grammar used by every other system parameter, with the same validation
(400 instead of silently capping page_size at 200). Their HTML debug
pages and next_url/previous_url builders use the new names.

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

57 KiB

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

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 and stability marker

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.).

JSON endpoints that are not part of the documented API include a marker key (UNSTABLE_API_MESSAGE in utils/init.py):

"unstable": "This API is not part of Datasette's stable interface and may change at any time"

Currently: the homepage (/.json, /-/.json), /db/-/queries/analyze, POST /db/-/queries/store, /db/<query>/-/definition, /db/-/query/parameters, /db/-/execute-write/analyze and the POST /-/permissions playground response.

Error shape (canonical)

Every JSON error response uses one canonical shape, built by error_body() (utils/init.py):

{
  "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:

    {"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 (and internal HTML-only names) on data formats return 400 Unknown _extra: <names>; HTML pages ignore them. 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 ForbiddenHTML 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 — a list of database objects (undocumented API, subject to change). Each item: 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) 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 permissions-debug (exposes runtime internals). 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=Noneaccessible 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": "..."}]}; .mdtext/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), _size (default 50, maximum 200, accepts max; out-of-range → 400).
  • 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}
    • queriesup 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 or blank → 400 "?sql= is required" for all data formats (.json, .csv, plugin formats). The HTML page remains the SQL editor.
  • .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.
  • Success201:
    {"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:

    {"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 permission check runs first, so unauthorized actors cannot probe for database existence.
  • Responses: .json → 200 {"ok": true, "database": "<name>", "schema": "<SQL>"} (concatenated sqlite_master.sql joined with ;\n); .mdtext/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 ForbiddenHTML 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
next_url absolute URL of the next page, 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. Requesting count implicitly includes count_truncated
count_truncated true when count hit the counting limit (the real count is at least the reported value)
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 (0no-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"}; .mdtext/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.

  • Response201 {"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).
  • Response200 (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:

    {"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, "rows": [{...}]} — a single-item list, matching insert/upsert. 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 400 with the message, matching update. 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):

{
  "database": "...", "name": "...", "sql": "...",
  "title": null, "description": null, "description_html": null,
  "hide_sql": false, "fragment": null,
  "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
}

private appears only in list responses. On input (create/update and datasette.yaml), params is accepted as an alias for parameters.

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; accepts max; values over max_returned_rows or non-integers → 400, matching table _size semantics), _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). Trusted queries → 403 "Trusted queries cannot be deleted using the API", matching update.
  • 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: a dstok_-prefixed token that fails verification — allow_signed_tokens off, invalid signature, missing/non-integer t, malformed d, or expired — raises TokenInvalid, and the request fails with 401, the canonical error body and a WWW-Authenticate: Bearer error="invalid_token" header (even if a valid ds_actor cookie is also present). Tokens with prefixes no registered handler recognizes are ignored (they may belong to an auth plugin). 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.

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.

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.


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)