On this page
Technical Overview
Pub/sub channels as queryable tables
WebSocket and Redis Pub/Sub channels become something you can subscribe to from SQL. Incoming messages buffer into queryable tables; outgoing messages queue with delivery status — all in one process, with no separate consumer service to operate.
The model: buffers in, a delivery queue out
Radio runs an asynchronous I/O thread per subscription so SQL never blocks on the wire. Inbound messages land in a per-subscription in-memory buffer that Radio exposes as ordinary table functions — each row carries a seen_count so re-querying drains only what's new, and the rows JOIN, WHERE, GROUP BY, and CREATE TABLE AS like any other table. Outbound messages go the other direction: you hand one to a transmit queue that delivers asynchronously and exposes per-message status plus per-subscription success/failure counters, so retries and audits are themselves SQL. That single shape — buffer incoming into tables, queue outgoing with delivery status — covers live dashboards refreshed on a timer, SQL-driven event responders that read then publish a reply, and ad-hoc inspection where you drain an unfamiliar channel into a table and GROUP BY to learn its shape; you can even inject canned messages into a subscription's inbox to test downstream SQL without a real upstream.
Supported transports
Two transports are wired up today, and the table-function surface stays identical regardless of protocol — you subscribe, drain, and transmit the same way whether the bytes arrive over a WebSocket or a Redis channel.
-
•
WebSocket: A standard RFC 6455 client.
ws://for plaintext orwss://for TLS; the extension handles ping/pong and reconnect internally. Fits browser-style real-time feeds, event streams, and any custom server-sent API that speaks WebSocket. -
•
Redis Pub/Sub: Point a subscription at a Redis URL plus channel name and Radio handles the Redis-specific
SUBSCRIBEandPUBLISHunder the same SQL surface. Fits application data flows that already use Redis as the bus. - • Roadmap: Google Pub/Sub, Azure Service Bus, MQTT, and subprocess pipes are listed as planned on the extension's documentation page. Only WebSocket and Redis Pub/Sub exist today.
Production caveats
What to know before pointing this at anything important. Radio trades durability for the simplicity of living entirely inside one DuckDB process.
- • In-memory, process-scoped buffers: Subscriptions, the received-message buffer, and the transmit queue all live in DuckDB process memory. When DuckDB exits, subscriptions tear down and unread messages are lost. For at-least-once delivery across restarts, drain to a durable store before processing — a standalone consumer service can crash-restart from the bus, Radio cannot.
- • Fixed-capacity receive buffer: The inbound buffer is size-limited; older messages are evicted as new ones arrive. Drain at a cadence that keeps up with the inbound rate so you don't lose messages to eviction.
-
•
Best-effort outbound delivery: Transmit returns once the message is queued, not once it's delivered — the I/O thread updates each message's status asynchronously. Per-subscription
transmit_successes/transmit_failurescounters and the per-message status table expose progress so you can build retry logic in SQL. - • Single connection per subscription: Subscription state is not shared across DuckDB connections in the same process. Open a subscription on the same connection that will drain it.
- • Experimental status: Function shapes may change as more transports land. Pin a known-good extension version in production.
Deep Dive
Technical Details
What you can do with three statements
-- 1. Open a WebSocket subscription. The URL itself is the key.CALL radio_subscribe('wss://stream.example.com/events');
-- 2. (later, or in another query) drain the inboxSELECT receive_time, channel, messageFROM radio_subscription_received_messages('wss://stream.example.com/events')ORDER BY receive_time DESCLIMIT 100;
-- 3. Reply or broadcast backCALL radio_transmit_message( 'wss://stream.example.com/events', 'main', -- channel '{"type":"ack","at":"2026-04-28T12:00:00Z"}'::BLOB, 3, -- max_attempts INTERVAL '500 milliseconds' -- retry_delay);radio_subscribe opens the connection and starts buffering. radio_subscription_received_messages exposes that buffer as an ordinary table with columns (subscription_id, subscription_url, message_id, message_type, receive_time, seen_count, channel, message) — JOIN, WHERE, GROUP BY, CREATE TABLE AS. radio_transmit_message queues an outbound message; poll radio_subscriptions for the per-subscription transmit_successes / transmit_failures counters.
Subscriptions, the received-message buffer, and the transmit queue all live in DuckDB process memory. Three things follow:
- Process exit drops everything. When DuckDB shuts down, subscriptions are torn down and unread messages are lost. For at-least-once delivery across restarts, persist drained messages to a durable store before processing.
- The received buffer is size-limited. Older messages are evicted as new ones arrive — drain with
radio_received_messagesat a cadence that keeps up with your inbound rate. - Outbound delivery is asynchronous and best-effort.
radio_transmit_messagereturns once the message is queued; the per-subscriptiontransmit_successes,transmit_failures,transmit_last_success_time, andtransmit_last_failure_timecolumns onradio_subscriptionsreport progress.
Status is experimental. The function surface may change as more transports get added.
Architecture
Radio runs an asynchronous I/O thread per subscription. For WebSocket endpoints it speaks the standard frame protocol; for Redis Pub/Sub endpoints it opens a Redis connection and SUBSCRIBEs to the channel(s). Inbound messages land in a per-subscription FIFO buffer; outbound messages live in a transmit queue with a status field that the I/O thread updates as delivery completes.
DuckDB SQL never blocks on the wire — radio_received_messages and friends are pure table scans over the in-process buffer. If you want to wait for fresh messages before reading, radio_listen blocks the connection until something arrives or a timeout elapses, and radio_sleep is a convenience for polling loops.
Supported transports today
- WebSocket —
ws://for plaintext,wss://for TLS. Subscribe with the URL inradio_subscribe. Standard RFC 6455 framing; the extension handles ping/pong and reconnect internally. - Redis Pub/Sub — point a subscription at a Redis URL plus channel name. Same
radio_subscribe/radio_received_messages/radio_transmit_messagesurface; Radio handles the Redis-specificSUBSCRIBEandPUBLISHunder the hood.
The extension’s documentation page lists planned transports — Google Pub/Sub, Azure Service Bus, MQTT, subprocess pipes — but only WebSocket and Redis Pub/Sub are wired up today.
Compared to alternatives
- A standalone consumer service — the typical pattern (Node.js / Python listening to WebSocket → write to a database → DuckDB queries the database). Radio collapses that into one process and keeps the data in DuckDB’s vectorized executor without a serialization hop. The trade is durability: a real consumer service can crash-restart from the bus.
- The
shellfsextension pipingwscat/websocatoutput — works for read-only WebSocket streams, but each pipe is a one-shot command without bidirectional support, status tracking, or reconnect. - Kafka via
tributary— different kind of bus. Tributary is for snapshotting / scanning Kafka topics; Radio is for live channel subscribe-and-send semantics on WebSocket / Redis Pub/Sub.
Install
INSTALL radio FROM community;
LOAD radio;
Quick Start
Subscribe to a WebSocket channel — the URL is the subscription key
CALL radio_subscribe('wss://stream.example.com/events');
Drain buffered messages as a normal table
SELECT receive_time, channel, message
FROM radio_subscription_received_messages('wss://stream.example.com/events')
ORDER BY receive_time DESC
LIMIT 50;
Send a message — args are (url, channel, payload, max_attempts, retry_delay)
CALL radio_transmit_message(
'wss://stream.example.com/events',
'main',
'{"type":"hello"}'::BLOB,
3,
INTERVAL '500 milliseconds'
);
Reference
Extension Contents
Quick reference to all available functions and settings organized by category.
| Name | Type | Description |
|---|---|---|
|
Inbox
Queryable table views of received messages — across all subscriptions or filtered to one. Drain like any DuckDB table; the buffer is in-process and FIFO per subscription. |
||
| radio_received_messages() | Table function exposing every received message (across subscriptions) as queryable rows. | |
| radio_subscription_received_message_add() | Inject a message into a subscription's received buffer. | |
| radio_subscription_received_messages() | Per-subscription view of received messages. | |
|
Listening
Open and manage subscriptions to remote event sources (WebSockets, message buses). Each subscription has a name and an endpoint; incoming messages are buffered for SQL to drain. |
||
| radio_listen() | Open a listener on a channel — buffers incoming messages so subsequent SELECTs can drain them. | |
| radio_subscribe() | Subscribe to a remote endpoint (WebSocket / event bus) under a logical subscription name. | |
| radio_subscriptions() | Table function listing every active subscription with its endpoint and state. | |
| radio_unsubscribe() | Tear down a subscription opened with radio_subscribe. | |
|
Maintenance
Buffer flushing, polling sleeps, and version inspection. |
||
| radio_flush() | Drain all currently-buffered messages so subsequent reads see only fresh data. | |
| radio_sleep() | Sleep the current connection — convenience for waiting between polling intervals when scripting. | |
| radio_version() | Return the loaded Radio extension version. | |
|
Outbox
Send messages out, with built-in delivery tracking. Queue, inspect, retract, and garbage-collect — all from SQL. |
||
| radio_subscription_transmit_message_delete() | Remove a queued outgoing message that hasn't yet been transmitted. | |
| radio_subscription_transmit_messages() | Table view of pending and sent outgoing messages with delivery status — what's been transmitted, what's queued, what failed. | |
| radio_subscription_transmit_messages_delete_finished() | Garbage-collect already-delivered outgoing messages from the transmit log. | |
| radio_transmit_message() | Queue an outgoing message on a subscription. | |
No extension contents match that search.
API Reference
Function Documentation
Practical Examples
Cookbook
Real-world recipes and patterns for common use cases.
Subscribe to a WebSocket channel
-- Open a subscription. The URL itself is the subscription identifier —-- pass it to the read / send / unsubscribe functions later.CALL radio_subscribe('wss://stream.example.com/events');
-- Inspect what's subscribedSELECT * FROM radio_subscriptions();The URL is what every other radio_* function takes as its subscription key. If you’ll reuse it across many statements, stash it in a SQL variable and bind via :feed:
SET VARIABLE feed = 'wss://stream.example.com/events';
CALL radio_subscribe(getvariable('feed'));
SELECT * FROM radio_subscription_received_messages(getvariable('feed'));See radio_subscribe and radio_subscriptions for the full signatures, including the optional named parameters for buffer sizing and transmit retry tuning.
Subscribe to Redis Pub/Sub
Same function, different URL — Radio routes WebSocket and Redis Pub/Sub through the same surface:
CALL radio_subscribe('redis://cache.example.com:6379/0?channel=events');
-- Drain the same way as a WebSocket subscription, keyed by URLSELECT receive_time, messageFROM radio_subscription_received_messages('redis://cache.example.com:6379/0?channel=events')ORDER BY receive_time DESC;Drain received messages
-- Recent messages across all subscriptionsSELECT subscription_url, channel, receive_time, messageFROM radio_received_messages()ORDER BY receive_time DESCLIMIT 100;-- One feed only — pass the same URL you subscribed withSELECT receive_time, channel, messageFROM radio_subscription_received_messages('wss://stream.example.com/events')ORDER BY receive_time DESC;The full row shape from radio_received_messages() / radio_subscription_received_messages(url) is (subscription_id UBIGINT, subscription_url VARCHAR, message_id UBIGINT, message_type, receive_time TIMESTAMP_MS, seen_count UBIGINT, channel VARCHAR, message BLOB). The payload is a BLOB — cast it to VARCHAR for text or use DuckDB’s JSON functions for structured payloads. These are normal DuckDB table functions — JOIN, filter, aggregate, push to Parquet, anything you’d do with a regular table. See radio_received_messages and radio_subscription_received_messages.
Wait for fresh messages before reading
For loops that need to act as messages arrive (rather than polling on a timer), radio_listen blocks the connection until something lands in the inbox or a timeout elapses:
-- Block up to 30 seconds for a message on any subscription.-- Args: (return_on_first_message, max_wait).CALL radio_listen(true, INTERVAL '30 seconds');
-- Then drainSELECT * FROM radio_received_messages() ORDER BY receive_time DESC LIMIT 1;Pair with radio_sleep when scripting between polls.
Send a message and track delivery
-- Args: (subscription_url, channel, payload, max_attempts, retry_delay).CALL radio_transmit_message( 'wss://stream.example.com/events', /* channel */ 'main', /* payload */ '{"type":"hello","at":"2026-04-28T12:00:00Z"}'::BLOB, /* max_attempts */ 3, /* retry_delay */ INTERVAL '500 milliseconds');
-- Inspect transmit queue + delivery stateSELECT message_id, messageFROM radio_subscription_transmit_messages('wss://stream.example.com/events')ORDER BY message_id DESC;Per-subscription delivery counters live on radio_subscriptions (transmit_successes, transmit_failures, transmit_last_success_time, transmit_last_failure_time); poll those to confirm delivery progress. See radio_transmit_message and radio_subscription_transmit_messages.
Garbage-collect the outbox
-- Drop all already-delivered transmit messagesCALL radio_subscription_transmit_messages_delete_finished('wss://stream.example.com/events');-- Or drop one specific pending outgoing message — message_id is the-- UBIGINT returned by radio_subscription_transmit_messages.message_id.CALL radio_subscription_transmit_message_delete('wss://stream.example.com/events', :message_id::UBIGINT);See radio_subscription_transmit_messages_delete_finished and radio_subscription_transmit_message_delete.
Test pipelines without a real upstream
Inject messages straight into a subscription’s inbox to exercise downstream SQL — no broker, no real WebSocket, no flake:
CALL radio_subscription_received_message_add( 'wss://stream.example.com/events', 'channel-name', '{"type":"test","value":42}'::BLOB);
SELECT * FROM radio_subscription_received_messages('wss://stream.example.com/events');See radio_subscription_received_message_add. Useful for unit-testing pipelines that consume radio_* tables.
Tear down
-- Close the subscriptionCALL radio_unsubscribe('wss://stream.example.com/events');See radio_unsubscribe.
Diagnostics
SELECT radio_version(); -- e.g. '0.4.x'Use radio_version to confirm what’s loaded when matching against bug reports. radio_flush drains all currently-buffered messages so subsequent reads see only fresh data.
Platform 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 Not available
- WASM Not available
Compiled binary sizes
| Platform | Architecture | Size |
|---|---|---|
| Linux | x86_64 | 6.26 MB |
| Linux | aarch64 | 6.16 MB |
| macOS | Intel | 3.60 MB |
| macOS | Apple Silicon | 3.73 MB |
Compressed download size from the Haybarn extension repository.
DuckDB & Haybarn
Release calendar- DuckDB v1.5.5 Haybarn 1.5.5-rc1 Supported