| name | datasette-plugin-writer |
| description | Guide for writing Datasette plugins. This skill should be used when users want to create or develop plugins for Datasette, including information about plugin hooks, the cookiecutter template, database APIs, request/response handling, and plugin configuration. |
| license | Apache-2.0 |
Writing Datasette Plugins
Use this skill to build plugins for Datasette, the open source multi-tool for exploring and publishing data.
Quick Start with Cookiecutter Template
Start a new plugin using the datasette-plugin cookiecutter template with newline-delimited variables:
echo "plugin_name
description of plugin
plugin-hyphenated-name
plugin_underscored_name
github_username
Author Name
y
y" | uvx cookiecutter gh:simonw/datasette-plugin
Example for a plugin called "my-cool-plugin":
echo "my cool plugin
A plugin that does cool things
my-cool-plugin
my_cool_plugin
username
Your Name
y
y" | uvx cookiecutter gh:simonw/datasette-plugin
The last two y responses enable static/ and templates/ directories.
After creating the plugin:
cd datasette-my-cool-plugin
python -m venv venv
source venv/bin/activate
pip install -e '.[test]'
datasette plugins
python -m pytest
Plugin Structure
A typical plugin structure:
datasette-my-plugin/
├── datasette_my_plugin/
│ ├── __init__.py # Plugin hooks go here
│ ├── static/ # Optional: CSS, JavaScript
│ └── templates/ # Optional: Custom templates
├── tests/
│ └── test_my_plugin.py
├── setup.py or pyproject.toml
└── README.md
Essential Plugin Hooks
prepare_connection(conn, database, datasette)
Register custom SQL functions. Called when SQLite connections are created:
from datasette import hookimpl
@hookimpl
def prepare_connection(conn):
conn.create_function("hello_world", 0, lambda: "Hello world!")
register_routes(datasette)
Add custom URL routes. Return list of (regex, view_function) pairs:
from datasette import hookimpl, Response
async def my_page(request):
return Response.html("<h1>Hello!</h1>")
@hookimpl
def register_routes():
return [
(r"^/-/my-page$", my_page)
]
View functions can accept: datasette, request, scope, send, receive.
render_cell(row, value, column, table, database, datasette, request)
Customize how table cell values are displayed:
from datasette import hookimpl
import markupsafe
@hookimpl
def render_cell(value, column):
if column == "stars":
return markupsafe.Markup("⭐" * int(value))
extra_template_vars(template, database, table, columns, view_name, request, datasette)
Add variables to template context:
@hookimpl
def extra_template_vars(request, datasette):
return {
"user_agent": request.headers.get("user-agent"),
"custom_data": "value"
}
Can also return async functions for database queries.
table_actions(datasette, actor, database, table, request)
Add menu items to table pages:
@hookimpl
def table_actions(datasette, database, table):
return [{
"href": datasette.urls.path(f"/-/export/{database}/{table}"),
"label": "Export this table",
"description": "Download as CSV"
}]
actor_from_request(datasette, request)
Implement authentication. Return actor dict or None:
@hookimpl
def actor_from_request(request):
token = request.args.get("_token")
if token == "secret":
return {"id": "user123", "name": "Alice"}
Can return async function for database lookups.
permission_allowed(datasette, actor, action, resource)
Control permissions. Return True (allow), False (deny), or None (no opinion):
@hookimpl
def permission_allowed(actor, action, resource):
if action == "execute-sql" and actor and actor.get("id") == "admin":
return True
Request and Response Objects
Request Object
Available in many plugin hooks:
request.method
request.url
request.path
request.full_path
request.query_string
request.args
request.args.get("key")
request.args.getlist("key")
request.headers
request.cookies
request.actor
request.url_vars
body = await request.post_body()
form_vars = await request.post_vars()
Response Object
Create responses in view functions:
from datasette.utils.asgi import Response
return Response.html("<h1>Hello</h1>")
return Response.json({"status": "ok"})
return Response.text("Plain text")
return Response.redirect("/other-page")
return Response(
body="Content",
status=200,
headers={"X-Custom": "value"},
content_type="text/plain"
)
response = Response.html("<h1>Hello</h1>")
response.set_cookie("session", datasette.sign({"id": "123"}, "cookie"))
Database API
Access databases in plugins:
db = datasette.get_database("mydb")
db = datasette.get_database()
results = await db.execute("SELECT * FROM mytable WHERE id = ?", [123])
for row in results:
print(row["column_name"])
results.rows
results.columns
results.truncated
results.first()
results.single_value()
await db.execute_write(
"INSERT INTO mytable (name) VALUES (?)",
["value"]
)
await db.execute_write_many(
"INSERT INTO mytable (id, name) VALUES (?, ?)",
[(1, "Alice"), (2, "Bob")]
)
def insert_and_count(conn):
conn.execute("INSERT INTO mytable (name) VALUES (?)", ["Alice"])
return conn.execute("SELECT COUNT(*) FROM mytable").fetchone()[0]
count = await db.execute_write_fn(insert_and_count)
tables = await db.table_names()
views = await db.view_names()
columns = await db.table_columns("mytable")
exists = await db.table_exists("mytable")
Plugin Configuration
Users configure plugins in datasette.yaml:
plugins:
datasette-my-plugin:
api_key: secret123
enabled: true
Or per-database:
databases:
mydb:
plugins:
datasette-my-plugin:
setting: value
Access in plugin code:
config = datasette.plugin_config("datasette-my-plugin")
api_key = config.get("api_key") if config else None
config = datasette.plugin_config(
"datasette-my-plugin",
database="mydb",
table="mytable"
)
Configuration lookup: table → database → instance level.
Static Assets and Templates
Static Files
Place in static/ directory, reference with:
url = datasette.urls.static_plugins("datasette_my_plugin", "app.js")
<script src="{{ urls.static_plugins('datasette_my_plugin', 'app.js') }}"></script>
Templates
Place in templates/ directory. Override Datasette templates:
database.html - Database page
table.html - Table page
row.html - Row page
query.html - Query page
Access template functions:
{{ csrftoken() }} {# CSRF token for forms #}
{{ urls.instance() }} {# Homepage URL #}
{{ urls.database("mydb") }} {# Database URL #}
{{ urls.table("mydb", "mytable") }} {# Table URL #}
Common Patterns
Add a custom SQL function
@hookimpl
def prepare_connection(conn):
import hashlib
def md5(text):
return hashlib.md5(text.encode()).hexdigest()
conn.create_function("md5", 1, md5)
Add a custom page
@hookimpl
def register_routes(datasette):
async def stats_page(request):
db = datasette.get_database()
tables = await db.table_names()
return Response.html(
await datasette.render_template(
"stats.html",
{"tables": tables},
request=request
)
)
return [(r"^/-/stats$", stats_page)]
Render custom output format
@hookimpl
def register_output_renderer(datasette):
def render_csv(columns, rows):
import csv, io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
writer.writerows(rows)
return Response(
output.getvalue(),
content_type="text/csv"
)
return {
"extension": "csv",
"render": render_csv
}
Check permissions
@hookimpl
def register_routes(datasette):
async def admin_page(request):
allowed = await datasette.permission_allowed(
request.actor,
"admin-page",
default=False
)
if not allowed:
from datasette import Forbidden
raise Forbidden("Admin access required")
return Response.html("<h1>Admin Page</h1>")
return [(r"^/-/admin$", admin_page)]
URL Design
Use /-/ prefix to avoid conflicts with database names:
/-/my-plugin - Instance-level page
/dbname/-/my-plugin - Database-level page
/dbname/table/-/my-plugin - Table-level page
Build URLs with base_url support:
datasette.urls.path("/-/my-page")
datasette.urls.database("mydb")
datasette.urls.table("mydb", "mytable")
Testing Plugins
Create tests in tests/test_my_plugin.py:
from datasette.app import Datasette
import pytest
@pytest.mark.asyncio
async def test_my_plugin():
datasette = Datasette()
await datasette.invoke_startup()
response = await datasette.client.get("/-/my-page")
assert response.status_code == 200
db = datasette.get_database()
result = await db.execute("SELECT hello_world()")
assert result.first()[0] == "Hello world!"
Run tests: pytest
Publishing
To GitHub
git init
git add .
git commit -m "Initial commit"
git branch -m main
git remote add origin git@github.com:username/datasette-my-plugin.git
git push -u origin main
To PyPI
Configure GitHub release environment with PyPI trusted publisher, then create a GitHub release matching your version number. The GitHub Action will automatically publish to PyPI.
Key Resources
Important Notes
- Accept only the parameters you need in hook functions (dependency injection)
- Use
@hookimpl decorator for all plugin hooks
- Async functions need
async def and await
- CSRF tokens required for POST forms:
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
- Plugin package name uses underscores, pip name uses hyphens
- Use
- prefix in URLs: /-/plugin-path
- Access internal database with
datasette.get_internal_database()