Events
Hook into DuckDB's internal events — query begin/end, transactions, connections — and ship them as JSON to any external program via stdin.
On this page
Technical Overview
DuckDB's internal lifecycle, as a JSON stream
Capture DuckDB's internal lifecycle — queries executed, transactions opened and closed, errors raised, connections churning — and stream the timeline as JSON lines into any external program. In-process telemetry, audit trails, cost tracking, and SIEM feeds, all wired up with PRAGMAs and no application code.
What it is
Events makes the otherwise-opaque inside of a DuckDB process observable. Instead of instrumenting application code or scraping logs after the fact, you get a structured JSON line for every query, transaction, and connection the process handles. That stream is the raw material for audit trails (every statement with timestamps and error status), error alerting (page on has_error), per-session cost attribution, and transaction forensics — but the extension itself is deliberately small: it emits events, and what you do with them lives entirely in the handler you point it at.
How it works
The extension hooks DuckDB's internal event surface, launches the program at events_destination as a long-lived child process, and writes one JSON object per event to its stdin. There is no SQL function to call — configuration is entirely PRAGMA-driven, so wiring up event capture is a few SET statements.
-
•
Configure with PRAGMAs:
events_destinationis a fully-qualified executable path (invoked directly, not via a shell —python3 handler.pywon't work; point at a shebang script or the interpreter binary).events_typespicks which events to capture,events_session_nametags every event, andevents_asyncswitches delivery mode. -
•
Seven event types:
connection_opened,connection_closed,query_begin,query_end,transaction_begin,transaction_commit, andtransaction_rollback. The default capture is['query_begin', 'query_end']— the smallest set that still gives query-level audit visibility, two events per statement. -
•
JSON lines on stdin: Every event is one line of JSON. A common envelope (
event,timestampin ISO 8601,database_path,session_name,connection_id,process_id) carries per-type fields on top —query_id(pairsquery_begin/query_end),transaction_id,has_error,error_message,error_type, andattached_databases[]. The handler can be a shell script, a Python service, a Go binary — anything that reads stdin a line at a time. -
•
Sync or fire-and-forget delivery: Synchronous (the default) writes the event and waits for the handler to consume it before the originating query returns — reliable, but adds latency to every query.
SET events_async = TRUEis fire-and-forget: no blocking, but events are dropped silently if the handler stalls, dies, or can't keep up. There's no in-between mode; pick one per session.
Production caveats
What to know before pointing this at a production database.
-
•
Process-scoped, not durable: Configuration lives in the running DuckDB process — restart and it's gone. Set the PRAGMAs at session start or wrap them in a startup script. The destination can be durable (file, S3, Kafka, syslog); the configuration is not. Same caveat as the sibling
cronjobextension — both are operate-your-DuckDB-process tools, observable only while the process is up. -
•
One handler per session: Only one destination at a time. For fan-out — file plus Kafka plus alerting — point
events_destinationat a small dispatcher that reads stdin and forks the stream itself (tee, a Python script,vector, etc.). There's also no per-query filter at the extension layer; every event of an enabled type is emitted, so filter downstream in the handler. -
•
Sync mode blocks queries: In the default synchronous mode the handler is on the critical path — a 50 ms-per-event handler adds 50 ms to every query, and a hung handler holds up everything. Add timeouts and a fast path, or accept the trade-off and switch to
events_async = TRUE. -
•
Handler runs with DuckDB's privileges: The destination program is launched by the DuckDB process and inherits its privileges and environment. Treat the
events_destinationpath as security-sensitive — point it at a fully-qualified file that's write-protected from the database user.
Deep Dive
Technical Details
What you can do with four PRAGMAs
The single most useful pattern: turn every query DuckDB runs into a structured JSON line on the stdin of a program of your choice, with no SQL functions to call and no application code to change:
LOAD events;
SET events_destination = '/usr/local/bin/event-handler.py';SET events_types = ['query_begin', 'query_end'];SET events_session_name = 'analytics-replica';SET events_async = TRUE;From this point on, every statement you run produces a query_begin and query_end line on the handler’s stdin — paired by query_id, tagged with the session name, with has_error and error_message populated when something goes wrong. The handler can be a shell script, a Python service, a Go binary, or anything else that reads stdin a line at a time.
Events is configured per running DuckDB process, not in the database file. PRAGMAs are forgotten on restart — set them again at session start, or stash them in a startup script. The same caveat applies to the sibling cronjob extension: both are “operate your DuckDB process” tools and observability is only available while the process is up.
For a durable audit trail, point events_destination at a handler that writes to durable storage (file, S3, Kafka, syslog) — the destination is durable, the configuration is not.
How delivery works
The extension launches the program at events_destination as a child process and writes one JSON event per line to its stdin. The handler is a long-lived consumer — DuckDB does not re-spawn it per event.
- Synchronous (default). DuckDB writes the event line and waits for the handler to consume it before returning from the originating query. Reliable: every event the database emits is acknowledged by the handler before the query completes. The cost is latency on every query.
SET events_async = TRUE. Fire-and-forget. DuckDB does not block on the handler. If the handler stalls, dies, or can’t keep up, events are dropped silently. Use for high-throughput workloads where “best-effort observability” is the right tradeoff.
There’s no in-between mode — pick one per session.
Event types and what they carry
All events share a common envelope: event, timestamp (ISO 8601), database_path, session_name, connection_id, process_id. Per-type fields layer on top:
| Event | Extra fields |
|---|---|
connection_opened, connection_closed |
(envelope only) |
query_begin |
query_id, transaction_id, attached_databases[] |
query_end |
query_id, transaction_id, has_error, error_message?, error_type?, attached_databases[] |
transaction_begin, transaction_commit |
transaction_id, start_timestamp, is_read_only |
transaction_rollback |
transaction_id, start_timestamp, is_read_only, has_error, error_message?, error_type? |
query_id correlates query_begin and query_end for the same statement; transaction_id ties query events to the surrounding transaction. attached_databases is an array of {name, path, type, read_only, temporary} records describing every database visible to the connection at event time — useful when you have multiple ATTACH-ed catalogs and want to attribute activity to one of them.
Choosing what to capture
The default ['query_begin', 'query_end'] is the right starting point: query-level audit visibility, two events per statement. Scale up or down as needed:
- Audit-only: keep the default. Two events per query, both with
query_idso you can pair them. - Errors-only:
['query_end']and filterhas_error = truein the handler — half the volume. - Connection churn: add
connection_opened,connection_closedto spot connection leaks or ephemeral session storms. - Transactional forensics: add the three
transaction_*events; group bytransaction_idto reconstruct exactly which statements ran inside each commit or rollback.
There’s no per-query filter at the extension layer — every event of an enabled type is emitted. Filter downstream in the handler if you need to.
Operational guidance
- Path must be fully qualified.
events_destinationis invoked directly, not via a shell.python3 my_handler.pywon’t work — point at the executable file (/usr/local/bin/my-handler.pywith a shebang, or/usr/bin/python3with the script asargv[1]if you wrap it). - The handler runs as DuckDB. It inherits the database process’s privileges and environment. Treat the destination path as security-sensitive: write-protect it from the database user.
- Sync mode = your handler is on the critical path. A 50ms-per-event handler adds 50ms to every query. Add timeouts and a fast path; or accept the tradeoff and switch to async.
- Use
events_session_namefor attribution. When several DuckDB processes feed the same handler — replicas, tenant-per-process layouts, CI runners — give each one a session name so events are distinguishable downstream.
Install
INSTALL events FROM community;
LOAD events;
Quick Start
Configure where events go and what to capture
SET events_destination = '/usr/local/bin/event-handler.py';
SET events_types = ['query_begin', 'query_end'];
Optional: don't block queries on the handler
SET events_async = TRUE;
Optional: tag every event with a session name
SET events_session_name = 'analytics-replica';
Reference
Extension Contents
Quick reference to all available functions and settings organized by category.
| Name | Type | Description |
|---|---|---|
|
Configuration
PRAGMAs that wire DuckDB's internal event stream to an external program. Set |
||
| events_async | When TRUE, events are delivered fire-and-forget — DuckDB doesn't wait for the handler before returning from the originating query. | |
| events_destination | Path to an external program that receives event JSON via stdin. | |
| events_session_name | Optional string included in every event. | |
| events_types | List of event types to capture. | |
No extension contents match that search.
Configuration
Settings
Configure the events extension behavior using these settings.
events_async
When TRUE, events are delivered fire-and-forget — DuckDB doesn't wait for the handler before returning from the originating query. Trades reliability for latency.
false
events_destination
Path to an external program that receives event JSON via stdin. Set to enable event forwarding; unset to disable. Must be a fully-qualified executable path.
—
events_session_name
Optional string included in every event. Useful for distinguishing events from multiple DuckDB processes feeding the same handler.
—
events_types
List of event types to capture. Available: 'connection_opened', 'connection_closed', 'query_begin', 'query_end', 'transaction_begin', 'transaction_commit', 'transaction_rollback'. Default: ['query_begin', 'query_end'].
[query_begin, query_end]
Practical Examples
Cookbook
Real-world recipes and patterns for common use cases.
Wire up a handler
LOAD events;
-- Required: a fully-qualified path to an executableSET events_destination = '/usr/local/bin/event-handler.py';
-- Pick the event types (default is ['query_begin', 'query_end'])SET events_types = ['query_begin', 'query_end'];
-- Optional: a tag included in every event lineSET events_session_name = 'analytics-replica';
-- Optional: don't block queries on the handlerSET events_async = TRUE;Append every query to a daily log file
The smallest possible audit trail — one JSONL file per day, written by a shell handler:
#!/usr/bin/env bash# /usr/local/bin/log-to-file.sh — append every event to a daily logexec >> "/var/log/duckdb/events-$(date +%F).jsonl"catSET events_destination = '/usr/local/bin/log-to-file.sh';SET events_types = ['query_begin', 'query_end'];
-- Every query now appends two lines to today's log fileSELECT count(*) FROM orders;Page on query errors
query_end includes has_error, error_message, and error_type when a query fails. Filter in the handler and post to your alerting system:
#!/usr/bin/env python3# /usr/local/bin/error-alerter.pyimport json, sys, urllib.request
for line in sys.stdin: e = json.loads(line) if e.get('event') == 'query_end' and e.get('has_error'): urllib.request.urlopen( 'https://alerts.example.com/page', data=line.encode(), )SET events_destination = '/usr/local/bin/error-alerter.py';SET events_types = ['query_end'];SET events_async = TRUE; -- don't let a slow alerter block queries
-- A failed query produces a query_end with has_error=trueSELECT * FROM nonexistent_table;Track transaction lifecycle
Capture the full begin/commit/rollback sequence and group by transaction_id:
SET events_destination = '/usr/local/bin/txn-tracker.py';SET events_types = ['transaction_begin', 'transaction_commit', 'transaction_rollback'];
BEGIN;INSERT INTO orders VALUES (1, 'widget', 99.99);COMMIT;-- Two events: transaction_begin and transaction_commit, same transaction_id.A transaction_rollback carries has_error and error_message when the rollback was triggered by a failure.
Per-tenant attribution
When several DuckDB processes feed the same handler, tag each one and group downstream by session_name:
-- In the per-tenant startup scriptSET events_destination = '/usr/local/bin/multi-tenant-collector.py';SET events_session_name = 'tenant-acme';SET events_types = ['query_begin', 'query_end'];SET events_async = TRUE;# /usr/local/bin/multi-tenant-collector.py — bucket events by tenantimport json, sys, collections
counts = collections.Counter()for line in sys.stdin: e = json.loads(line) if e.get('event') == 'query_end': counts[e.get('session_name', 'unknown')] += 1 # ... ship to your metrics backend, or write to per-tenant filesPair query_begin and query_end for latency
Both events share a query_id. Maintain a small in-memory map in the handler:
#!/usr/bin/env python3# /usr/local/bin/latency-recorder.pyimport json, sysfrom datetime import datetime
starts = {}for line in sys.stdin: e = json.loads(line) qid = e.get('query_id') ts = datetime.fromisoformat(e['timestamp'].replace('Z', '+00:00'))
if e['event'] == 'query_begin': starts[qid] = ts elif e['event'] == 'query_end' and qid in starts: latency_ms = (ts - starts.pop(qid)).total_seconds() * 1000 print(f"qid={qid} latency_ms={latency_ms:.1f} error={e.get('has_error')}", flush=True)SET events_destination = '/usr/local/bin/latency-recorder.py';SET events_types = ['query_begin', 'query_end'];Fan out to multiple consumers
Only one events_destination is allowed. Point it at a small dispatcher that forks the stream:
#!/usr/bin/env bash# /usr/local/bin/fanout.sh — split the event stream three waysexec tee \ >(cat >> /var/log/duckdb/events.jsonl) \ >(curl -s -X POST -H 'content-type: application/x-ndjson' \ --data-binary @- https://collector.example.com/ingest) \ > /dev/nullSET events_destination = '/usr/local/bin/fanout.sh';Re-applying configuration on session start
PRAGMAs do not persist across restarts (same caveat as the cronjob extension). Stash the four SET statements in a startup file that every connection runs:
-- /etc/duckdb/init.sqlLOAD events;SET events_destination = '/usr/local/bin/event-handler.py';SET events_types = ['query_begin', 'query_end'];SET events_session_name = 'prod-replica-1';SET events_async = TRUE;duckdb -init /etc/duckdb/init.sql /data/analytics.duckdbPlatform Support
Compatibility
Extension availability may vary by platform and DuckDB version. Check below to ensure this extension supports your environment before installation.
Quick Facts
Platforms
- Linux x86_64 aarch64
- Linux (musl) Not available
- macOS Intel Apple Silicon
- Windows x86_64
- WASM Not available
Compiled binary sizes
| Platform | Architecture | Size |
|---|---|---|
| Linux | x86_64 | 3.22 MB |
| Linux | aarch64 | 2.85 MB |
| macOS | Intel | 2.60 MB |
| macOS | Apple Silicon | 2.26 MB |
| Windows | x86_64 | 7.39 MB |
Compressed download size from the Haybarn extension repository.
DuckDB & Haybarn
Release calendar- DuckDB v1.5.5 Haybarn 1.5.5-rc1 Supported