| name | pgque-postgres-queue |
| description | Expert skill for PgQue – a zero-bloat, snapshot-based Postgres queue using pure PL/pgSQL with no C extensions required. |
| triggers | ["set up a postgres queue","use pgque for message queue","zero bloat postgres queue","pgq queue in postgres","fan-out event queue postgres","postgres queue without skip locked","install pgque","snapshot based postgres queue"] |
PgQue – Zero-Bloat Postgres Queue
Skill by ara.so — Daily 2026 Skills collection.
PgQue is a pure PL/pgSQL reimplementation of the battle-tested PgQ (Skype/Postgres) architecture. It uses snapshot-based batching and TRUNCATE-based table rotation instead of row-level locking, delivering zero dead-tuple bloat, predictable performance under sustained load, and native fan-out — all from a single SQL file on any Postgres 14+ instance including managed providers (RDS, Aurora, Cloud SQL, Supabase, Neon).
Key Concepts
- Tick: A periodic snapshot that closes a batch of events. Nothing is delivered until a tick fires.
- Batch: A group of events captured in one tick, consumed atomically by a subscriber.
- Subscriber/Consumer: A named cursor on the event log. Multiple consumers get independent copies of every batch (fan-out).
- Zero bloat: Events are stored in rotating tables and cleared via
TRUNCATE, never DELETE. No dead tuples.
- Latency trade-off: End-to-end delivery is ~1–2 s (one tick interval + poll). Per-call function latency is microseconds.
Installation
Requirements
- Postgres 14+
pg_cron (recommended) or an external scheduler calling pgque.ticker() every second
Install from SQL file
git clone https://github.com/NikolayS/pgque.git
cd pgque
PAGER=cat psql --no-psqlrc --single-transaction -d mydb -f sql/pgque.sql
Or inside a psql session:
begin;
\i sql/pgque.sql
commit;
Start the ticker (pg_cron)
select pgque.start();
Start the ticker (without pg_cron)
Run these externally on a schedule:
psql -d mydb -c "select pgque.ticker()"
psql -d mydb -c "select pgque.maint()"
Warning: Without a running ticker, consumers see nothing. Enqueue works, but no batches are created.
Uninstall
\i sql/pgque_uninstall.sql
Roles & Grants
| Role | Use |
|---|
pgque_reader | Dashboards, metrics, read-only |
pgque_writer | Producers and consumers (most apps) |
pgque_admin | Operators, migrations |
CREATE USER app_worker WITH PASSWORD '...';
GRANT pgque_writer TO app_worker;
CREATE USER metrics_reader WITH PASSWORD '...';
GRANT pgque_reader TO metrics_reader;
Core API (Modern Style)
Create a Queue
SELECT pgque.create_queue('orders');
Subscribe a Consumer
SELECT pgque.subscribe('orders', 'order-processor');
Send Events (Enqueue)
SELECT pgque.send('orders', 'new_order', '{"order_id": 42, "amount": 99.99}');
SELECT pgque.send_batch('orders', ARRAY[
ROW('new_order', '{"order_id": 43}')::pgque.event_data,
ROW('new_order', '{"order_id": 44}')::pgque.event_data
]);
Receive and Acknowledge Events
SELECT * FROM pgque.receive('orders', 'order-processor');
SELECT pgque.ack('orders', 'order-processor', :batch_id);
SELECT pgque.nack('orders', 'order-processor', :batch_id);
Unsubscribe
SELECT pgque.unsubscribe('orders', 'order-processor');
Low-Level PgQ API
These map directly to the original PgQ primitives and are also available via pgque_writer:
SELECT pgque.insert_event('orders', 'new_order', '{"order_id": 42}');
SELECT pgque.register_consumer('orders', 'order-processor');
SELECT pgque.next_batch('orders', 'order-processor');
SELECT * FROM pgque.get_batch_events(:batch_id);
SELECT pgque.finish_batch(:batch_id);
SELECT pgque.event_retry(:batch_id, :ev_id, 60);
SELECT pgque.unregister_consumer('orders', 'order-processor');
Complete Working Example
Producer (Python with psycopg2)
import psycopg2
import json
import os
conn = psycopg2.connect(os.environ["DATABASE_URL"])
conn.autocommit = False
def enqueue_order(order_id: int, amount: float):
with conn.cursor() as cur:
cur.execute(
"SELECT pgque.send(%s, %s, %s)",
("orders", "new_order", json.dumps({"order_id": order_id, "amount": amount}))
)
conn.commit()
enqueue_order(42, 99.99)
Consumer (Python with psycopg2)
import psycopg2
import psycopg2.extras
import json
import os
import time
conn = psycopg2.connect(os.environ["DATABASE_URL"])
conn.autocommit = False
QUEUE = "orders"
CONSUMER = "order-processor"
def setup():
with conn.cursor() as cur:
cur.execute("SELECT pgque.subscribe(%s, %s)", (QUEUE, CONSUMER))
conn.commit()
def process_batch():
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM pgque.receive(%s, %s)", (QUEUE, CONSUMER))
rows = cur.fetchall()
if not rows:
conn.rollback()
return False
batch_id = rows[0]["batch_id"]
for row in rows:
event = json.loads(row["ev_data"])
print(f"Processing order {event['order_id']}")
with conn.cursor() as cur:
cur.execute("SELECT pgque.ack(%s, %s, %s)", (QUEUE, CONSUMER, batch_id))
conn.commit()
return True
setup()
while True:
if not process_batch():
time.sleep(1)
Fan-Out Example (Multiple Independent Consumers)
SELECT pgque.create_queue('user-events');
SELECT pgque.subscribe('user-events', 'analytics-service');
SELECT pgque.subscribe('user-events', 'notification-service');
SELECT pgque.subscribe('user-events', 'audit-log');
SELECT pgque.send('user-events', 'user_signup', '{"user_id": 1}');
SELECT * FROM pgque.receive('user-events', 'analytics-service');
SELECT * FROM pgque.receive('user-events', 'notification-service');
SELECT * FROM pgque.receive('user-events', 'audit-log');
Retry / Dead Letter Pattern
DO $$
DECLARE
v_batch_id bigint;
v_ev record;
BEGIN
SELECT pgque.next_batch('orders', 'order-processor') INTO v_batch_id;
IF v_batch_id IS NULL THEN
RAISE NOTICE 'No batch available';
RETURN;
END IF;
FOR v_ev IN SELECT * FROM pgque.get_batch_events(v_batch_id) LOOP
BEGIN
RAISE NOTICE 'Processing event % type %', v_ev.ev_id, v_ev.ev_type;
EXCEPTION WHEN OTHERS THEN
PERFORM pgque.event_retry(v_batch_id, v_ev.ev_id, 60);
RAISE NOTICE 'Event % queued for retry', v_ev.ev_id;
END;
END LOOP;
PERFORM pgque.finish_batch(v_batch_id);
END;
$$;
Monitoring & Introspection
SELECT * FROM pgque.get_queue_info();
SELECT * FROM pgque.get_queue_info('orders');
SELECT * FROM pgque.get_consumer_info();
SELECT * FROM pgque.get_consumer_info('orders');
SELECT * FROM pgque.get_consumer_info('orders', 'order-processor');
SELECT * FROM pgque.get_batch_info(:batch_id);
SELECT pgque.version();
Configuration & Tuning
Tick Frequency
SELECT cron.alter_job(
job_id := (SELECT jobid FROM cron.job WHERE command LIKE '%pgque.ticker%'),
schedule := '* * * * *'
);
Force Immediate Tick (Testing/Demos)
SELECT pgque.force_tick('orders');
SELECT pgque.ticker();
pg_cron Log Hygiene
ALTER SYSTEM SET cron.log_run = off;
SELECT pg_reload_conf();
SELECT cron.schedule('pgque-cron-purge', '0 * * * *',
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '1 hour'$$
);
pg_cron in Different Database
If pg_cron is in postgres DB but PgQue is in mydb:
SELECT cron.schedule_in_database(
'pgque-ticker', '* * * * *',
'SELECT pgque.ticker()', 'mydb'
);
SELECT cron.schedule_in_database(
'pgque-maint', '* * * * *',
'SELECT pgque.maint()', 'mydb'
);
Common Patterns
Transactional Enqueue (Send with Business Logic)
BEGIN;
INSERT INTO orders (id, amount) VALUES (42, 99.99);
SELECT pgque.send('orders', 'new_order', '{"order_id": 42}');
COMMIT;
Queue Depth Check Before Scaling
SELECT
queue_name,
ev_per_sec,
consumer_count,
pending_events
FROM pgque.get_queue_info()
WHERE pending_events > 1000;
List All Consumers with Lag
SELECT
queue_name,
consumer_name,
pending_events AS lag,
last_seen
FROM pgque.get_consumer_info()
ORDER BY lag DESC;
Troubleshooting
Consumers receive nothing
Cause: Ticker is not running.
SELECT * FROM pgque.get_queue_info('orders');
SELECT pgque.ticker();
SELECT * FROM cron.job WHERE command LIKE '%pgque%';
Events not appearing after send
SELECT pgque.ticker();
SELECT * FROM pgque.get_queue_info('orders');
SELECT * FROM pgque.get_consumer_info('orders', 'my-consumer');
Performance / VACUUM pressure
PgQue is immune to dead-tuple bloat in the event path by design. If you see VACUUM activity, it's from your own application tables, not from PgQue's queue tables.
Retry events not reappearing
SELECT pgque.maint();
SELECT * FROM pgque.get_queue_info('orders');
Upgrade / Reinstall
Upgrade paths are still being stabilized. To safely reinstall:
psql -d mydb -c "\i sql/pgque_uninstall.sql"
psql -d mydb --single-transaction -f sql/pgque.sql
psql -d mydb -c "select pgque.start()"
Architecture Summary
Producer → pgque.send() → event tables (rotating)
↓
pgque.ticker() ←── pg_cron (every 1s)
↓
batch snapshot created
↓
Consumer A → pgque.receive() → batch events → pgque.ack()
Consumer B → pgque.receive() → same batch → pgque.ack()
Consumer C → pgque.receive() → same batch → pgque.ack()
↓
pgque.maint() → TRUNCATE old tables
(zero dead tuples)