datasette-plugin-writer

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.

About datasette-plugin-writer

datasette-plugin-writer is a Claude AI skill developed by simonw. 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. This powerful Claude Code plugin helps developers automate workflows and enhance productivity with intelligent AI assistance.

0Stars
0Forks
2025-11-09

Why use datasette-plugin-writer? With 0 stars on GitHub, this skill has been trusted by developers worldwide. Install this Claude skill instantly to enhance your development workflow with AI-powered automation.

namedatasette-plugin-writer
descriptionGuide 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.
licenseApache-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 # Verify plugin is visible python -m pytest # Run tests

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 # "GET" or "POST" request.url # Full URL request.path # Path without query string request.full_path # Path with query string request.query_string # Query string without ? request.args # MultiParams object for query params request.args.get("key") # Get single param value request.args.getlist("key") # Get list of values request.headers # Dict of headers (lowercase keys) request.cookies # Dict of cookies request.actor # Current authenticated actor or None request.url_vars # Variables from URL regex # Async methods: body = await request.post_body() # Raw POST body as bytes form_vars = await request.post_vars() # Form data as dict

Response Object

Create responses in view functions:

from datasette.utils.asgi import Response # HTML response return Response.html("<h1>Hello</h1>") # JSON response return Response.json({"status": "ok"}) # Text response return Response.text("Plain text") # Redirect return Response.redirect("/other-page") # Custom response return Response( body="Content", status=200, headers={"X-Custom": "value"}, content_type="text/plain" ) # Set cookies response = Response.html("<h1>Hello</h1>") response.set_cookie("session", datasette.sign({"id": "123"}, "cookie"))

Database API

Access databases in plugins:

# Get database object db = datasette.get_database("mydb") # Named database db = datasette.get_database() # First database # Execute read query results = await db.execute("SELECT * FROM mytable WHERE id = ?", [123]) for row in results: print(row["column_name"]) # Query properties results.rows # List of Row objects results.columns # List of column names results.truncated # True if results were truncated results.first() # First row or None results.single_value() # Single value if query returned one # Execute write query await db.execute_write( "INSERT INTO mytable (name) VALUES (?)", ["value"] ) # Execute multiple writes await db.execute_write_many( "INSERT INTO mytable (id, name) VALUES (?, ?)", [(1, "Alice"), (2, "Bob")] ) # Execute function with write connection 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) # Introspection 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 # With database/table context 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:

# In Python url = datasette.urls.static_plugins("datasette_my_plugin", "app.js") # In templates <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): # Check if user has permission 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() # Test with client response = await datasette.client.get("/-/my-page") assert response.status_code == 200 # Test database operations 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()
simonw

simonw

research

View on GitHub

Download Skill Files

View Installation Guide

Download the complete skill directory including SKILL.md and all related files