The insert, upsert, alter and set-column-type endpoints previously required Content-Type: application/json while /-/create parsed the body blind - and insert returned a 500 AttributeError when the header was missing entirely. The lenient rule is now uniform: the body is always parsed as JSON and invalid JSON is a 400. This makes curl -d and requests data=json.dumps(...) invocations work without remembering the header. Cross-site request forgery remains prevented by the Origin and Sec-Fetch-Site checks in CrossOriginProtectionMiddleware, which is the defense the strict content-type requirement was historically standing in for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
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
- Instance endpoints
- Database endpoints
- Table and row read endpoints
- The write API
- Stored (canned) queries API
- Authentication and tokens
- 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.jsonextension returns JSON. - Table, row and query routes accept any
\w+format extension; formats other than the built-inhtml,json,csv,blobmust be provided by a plugin viaregister_output_renderer, otherwise the request 404s. - HTML responses include a
Link: <...>; rel="alternate"; type="application/json+datasette"header pointing at the.jsonvariant (views/base.py:141-159), unless the view opts out withhas_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.dumpsoutput serialized byCustomJSONEncoder; 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=onresponses usetext/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
}
errorsis a list of one or more message strings (multi-message validation errors, e.g. per-row insert errors, list them all).erroris the messages joined with"; ".statusalways matches the HTTP status code.
The shape is produced by four code paths, all delegating to error_body():
-
Exception handler (handle_exception.py) —
NotFound,DatasetteError,BadRequestetc. on.jsonpaths.DatasetteErrorerror_dictcontext keys are merged in; the legacytitlekey is no longer emitted in JSON (it survives in the HTML error template context). -
The
_error()helper (views/base.py:183-184) — the write API, stored-query API, execute-write and permission-denied paths. -
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=objectmisuse (on queries or pk-less tables) also return canonical 400 errors. -
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:
- Requests carrying
Authorization: Bearer ...and noCookieheader bypass the check entirely (csrf.py:98-110). - Otherwise
Sec-Fetch-Sitemust besame-originornone; other values → 403. - If neither
Sec-Fetch-SitenorOriginis present (curl, API clients), the request passes. - Fallback:
Originmust 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(defaultobjects):objects—{"ok": true, "rows": [{col: val}, ...], "truncated": false, ...}arrays— same envelope, each row a list of valuesarray— response body is a bare JSON array of row objectsarrayfirst— bare JSON array of the first column's valuesobject— 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=arrayonly: newline-delimited JSON,text/plain._json=COLUMN(repeatable) — parse that column's string values withjson.loadsso they nest as JSON; parse failures leave the value unchanged._json_infinity=1— preserveInfinity/-Infinity; by default they are replaced withnull.columnsis stripped from dict-shaped output unless?_extra=columnswas requested (renderer.py:110-113).- If a SQL error occurred,
_shapeis ignored, HTTP status is 400 and the envelope carries the canonical error keys alongsiderows/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
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 byview-database/view-tablefor the actor. - Parameters:
_sort=relationshipssorts each database's truncated table list by foreign-key relationship count. - JSON response (index.py:147-161) — includes
ok: trueplus: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=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 bydatasette --root. - Match → invalidates the token, sets the signed
ds_actorcookie 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_tokensmust be on; request must have an actor with anid; the actor must not itself be token-derived. - POST fields:
expire_type(""/minutes/hours/days),expire_duration(positive int), plus restriction checkboxes namedall:<action>,database:<db>:<action>,resource:<db>:<table>:<action>. - Response: HTML page containing the new
dstok_token. - Programmatic alternatives:
datasette create-tokenCLI ordatasette.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_sqlfor the current actor (default items come from thejump_items_sqlplugin 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, optionaldisplay_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-databaseare included (others silently omitted). - Formats: no extension → HTML;
.json→{"ok": true, "schemas": [{"database": name, "schema": "..."}]};.md→text/markdownrendering.
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, optionalresource_1,resource_2; returns JSON{"action", "allowed", "resource": {"parent", "child", "path"}}plusactor_idwhen 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
reasonfield if the actor also holdspermissions-debug. - Parameters:
action(required; missing → 400 canonical error, unknown → 404),parent,child(requiresparent),_page(default 1),_size(default 50, maximum 200, acceptsmax; out-of-range → 400). - Response:
{"action", "actor_id", "page", "page_size", "total", "items": [{"parent", "child", "resource"}]}with optionalnext_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_downloadoff or mutable database →Forbidden("Database download is forbidden"); no file path → 404. - Response: streamed
application/octet-streamwith acontent-dispositionattachment; immutable databases with a known hash setEtagand honorIf-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-databaseviacheck_visibility(denied →Forbidden→ 403 HTML). Table/view listings are filtered byview-table; stored queries byview-query. - Parameters:
?sql=— non-blank value 302-redirects to/<database>/-/query?...preserving the query string and format.- No
?_extra=and no_shapesupport — the JSON is built directly and returned viaResponse.json, bypassing the JSON renderer (views/database.py:189-212).
- JSON response (all keys always present):
ok— alwaystruedatabase— name;private— bool;path— URL path;size— bytestables— 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}),privatehidden_count— number of hidden tablesviews— 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 actortable_columns—{table: [columns]}, empty{}unlessallow_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-sqlon the database viacheck_visibility(denied →Forbidden→ 403 HTML). - Parameters:
sql— SQL to run. Must passvalidate_sql_select(utils/init.py:345-354): after stripping--comment lines it must start withselect,withor anexplainvariant, and must not containpragma(except allowlistedpragma_*()table-valued functions). Failure → 400DatasetteErrortitled"Invalid SQL"→ JSON{"ok": false, "error": "Statement must be a SELECT", "status": 400, "title": "Invalid SQL"}.- Any other
name=valuepair supplies the:namenamed 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: truewhen the result hitmax_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"(theerrorvalue 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.
- SQLite errors (e.g.
.csvstreams 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'andX-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— ifrows/rowprovided (need insert-row)update-row— ifreplace: true(need update-row)alter-table— ifalter: trueon 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 exclusivecolumns— list of{name, type, fk_table, fk_column, not_null, default, default_expr}; mutually exclusive withrows/row;typeone oftext/integer/float/blob(defaulttext);defaultanddefault_exprmutually exclusive;default_exprone ofcurrent_timestamp,current_date,current_time,current_unixtime,current_unixtime_ms. At least one ofcolumns/rows/rowrequired.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; requirerow/rowsandpk/pks.alter(bool) — add missing columns when inserting into an existing table.
- Success — 201:
{"ok": true, "database": "...", "table": "...", "table_url": "https://.../db/table", "table_api_url": "https://.../db/table.json", "schema": "CREATE TABLE ...", "row_count": 2}row_countonly when rows were inserted. Write failures → 400{"ok": false, "errors": ["<sqlite message>"]}. Emitscreate-table/insert-rows/alter-tableevents.
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 callsignored read of a table view-tableon that tableinsertorupdateall of insert-row,update-row,delete-rowon the tabledeletedelete-rowcreate tablecreate-tableon the databasealter table,create index,drop indexalter-tableon the tabledrop tabledrop-tableVACUUM, virtual-table writes, shadow-table writesrejected outright (403) statements touching attached databases rejected (403) -
Body: JSON (
{"sql": ..., "params": {...}}— only those two keys) or form-encoded (sqlplus 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_jsonfield 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}]}rowsis populated byRETURNINGclauses. 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→ 403errorsJSON. - Parameters: only
sqlallowed (else 400"Invalid keys: ..."). - Response — 200 even when analysis fails (
ok: falsein body):{"ok", "parameters", "analysis_error", "analysis_rows": [{operation, database, table, required_permission, source, allowed}], "execute_disabled", "execute_disabled_reason"}.allowedis 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-tableon the database, oralter-tableon?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;typeis 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>"}(concatenatedsqlite_master.sqljoined 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 |
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 (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=objectfails; base query has noorder by. - Pagination is offset-based:
_nextis an integer offset applied aslimit N offset M(views/table.py:2047-2049, 2438-2439) — unlike the keyset tokens used for tables. view_definitionreturns theCREATE VIEWSQL;table_definitionis 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;columnsonly 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 withcountandlink; single-pk rows only).- Foreign-key label expansion does not apply to row JSON —
_labelshas no effect here; expansion happens only in the HTML path (views/row.py:445-475). _shape,_json,_nl,_json_infinity,_ttlapply.
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-tableviaensure_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 escapedLIKE %q%against pk columns and the label column) and_initial(truthy: with emptyq, 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-rowon the table (denied → 403["Permission denied"]);update-rowadditionally required forreplace: true(403need update-row to use "replace");alter-tableadditionally required foralter: true(403Permission denied for alter-table). Immutable database → 403Database is immutable. -
Request — the body is parsed as JSON regardless of the request
Content-Typeheader (invalid JSON → 400). Body:Field Rules rowsingle object; mutually exclusive with rows; forcesreturn: truerowslist of objects; max max_insert_rows(default 100), else 400"Too many rows, maximum allowed is 100"ignoreskip rows whose pk already exists; mutually exclusive with replacereplacereplace rows with matching pks (needs update-row)alteradd missing columns (needs alter-table)returninclude inserted rows in the response One of
row/rowsrequired. Unknown keys → 400"Invalid parameter: ...". Unlessalter, 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}; withreturn: truealsorows(the rows as stored, re-fetched by rowid). SQLite errors during the write → 400 with the message. Emitsinsert-rows(and possiblyalter-table) events.
POST /<database>/<table>/-/upsert
TableUpsertView — subclasses insert (views/table.py:1197-1201).
- Permissions: both
insert-rowandupdate-row(403need both insert-row and update-row);alter: trueneedsalter-table. - Request: same as insert, except
ignore/replaceare rejected (400"Upsert does not support ignore or replace") and every row must contain the table's primary key(s) (per-row 400Row 0 is missing primary key column(s): "id"/has null primary key). - Response — 200 (note: insert returns 201)
{"ok": true}; withreturn: true,rowsre-fetched by pk. Emitsupsert-rows.
POST /<database>/<table>/-/alter
TableAlterView (views/table_create_alter.py:1130-1353).
-
Permission:
alter-table(403need alter-table); immutable → 403. -
Request:
{"operations": [{"op": ..., "args": {...}}, ...]}— a non-empty list, validated by pydantic (extra keys forbidden anywhere; errors → 400location: message):opargsadd_columnname(required),type(text/integer/float/blob, defaulttext),not_null,defaultxordefault_expr;not_null: truerequires a defaultrename_columnname,torename_tableto(must not startsqlite_)alter_columnname+ at least one oftype,not_null,default,default_exprdrop_columnnameset_primary_keycolumns(non-empty list)reorder_columnscolumns(non-empty list)add_foreign_keycolumn,fk_table, optionalfk_columndrop_foreign_keycolumnset_foreign_keysforeign_keys: list of{column, fk_table, fk_column?}default_exprmust be one of the fivecurrent_*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(403Permission 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"}. Withconfirm: true→ 200{"ok": true}. Emitsdrop-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(403Permission denied). - Request:
{"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 theregister_column_typeshook):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(403need 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(403Permission denied);alter: trueadditionally requiresalter-table(403Permission 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-dictupdate→ 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}; withreturn: true,{"ok": true, "rows": [{...}]}— a single-item list, matching insert/upsert. Emitsupdate-row.
POST /<database>/<table>/<pks>/-/delete
RowDeleteView (views/row.py:738-778).
- Permission:
delete-row(403Permission 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_tablearedirectkey is added. A failure during the write returns 400 with the message, matching update. Emitsdelete-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; acceptsmax; values overmax_returned_rowsor non-integers → 400, matching table_sizesemantics),_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", "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-sqlthenstore-query(each denial → 403errorsJSON). - 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(403errorsJSON). - 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 passvalidate_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_writeis derived from SQL analysis;is_trusted,description_htmlandon_success_message_sqlcannot 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(403need update-query); trusted queries → 403"Trusted queries cannot be updated using the API"; changingsqladditionally requiresexecute-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 andis_writerecomputed. - Response: 200
{"ok": true}(plusquerywithreturn: 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(403need 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". Noconfirmfield 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 requireexecute-sqlunlessis_trusted. Write queries are not executed on GET — JSON returns emptyrows; HTML shows a POST form. - Parameters: each named
:paramis 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 witherrorin the envelope.
POST (write queries) — QueryView.post (views/database.py:574-693):
- Permissions:
view-query; then, unlessis_trusted:execute-write-sqlon 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=valuepairs (values coerced to strings). - JSON is returned when
Accept: application/json,?_json=1, or a_jsonbody field is present; otherwise 302 + flash message. - Magic parameters (
:_<prefix>_<key>, resolved server-side; registered viaregister_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}—messagehonorson_success_message_sql/on_success_message, falling back to"Query executed"or"Query executed, N rows affected". SQL failure → 400 canonical error (message honorson_error_message) plus aredirectcontext key fromon_error_redirect. Operation rejection (QueryWriteRejected, e.g. VACUUM) → 403 canonical error plusredirect: 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 (namespacetoken) containinga(actor id),t(creation Unix time), optionald(duration seconds), optional_r(restrictions). - Verification: a
dstok_-prefixed token that fails verification —allow_signed_tokensoff, invalid signature, missing/non-integert, malformedd, or expired — raisesTokenInvalid, and the request fails with 401, the canonical error body and aWWW-Authenticate: Bearer error="invalid_token"header (even if a validds_actorcookie is also present). Tokens with prefixes no registered handler recognizes are ignored (they may belong to an auth plugin). The effective duration isdcapped bymax_signed_tokens_ttl(default 0 = no cap; a non-zero setting also imposes a TTL on tokens withoutd). - 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-tokenis 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-tokenCLI command and thedatasette.create_token()Python API. /-/auth-tokenis the one-time--rootlogin 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.
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) |