This is the multi-page printable view of this section. .
Toolbox
-
1: ZFS Storage
-
2: PGMQ Queues
-
3: SQLite + Litestream
-
4: TimescaleDB
Tools and guides that extend pgcli beyond core instance management.
- ZFS Storage — Install ZFS and manage pools, datasets and snapshots
- PGMQ Queues — Build durable message queues inside PostgreSQL
- SQLite + Litestream — Manage host-local SQLite databases with continuous S3 backups
- TimescaleDB — Time-series hypertables and time_bucket analytics on PostgreSQL
1 - ZFS Storage
pg zfs turns a Linux host into a ZFS storage server: install the packages, inspect
disks, create redundancy-aware pools, and manage datasets and snapshots. It talks
directly to the kernel — no state is written to pg.yaml, and it does not need a
PostgreSQL instance.
The command family is deliberately resource-oriented: pg zfs pool create,
pg zfs dataset create, pg zfs snapshot rollback. Anything not wrapped is
reachable through pg zfs raw.
Supported systems
pg zfs install detects the distribution from /etc/os-release and installs
everything needed, including package repositories:
| Distribution | ZFS source | Version tested |
|---|---|---|
| Debian 12 (bookworm) | Debian contrib, DKMS |
2.1.11 |
| Debian 13 (trixie) | Debian contrib, DKMS |
2.3.9 |
| Ubuntu 24.04 (noble) | Ubuntu universe |
2.2.2 from the Ubuntu kernel package |
| Ubuntu 26.04 | Ubuntu universe |
2.4.1 |
| Fedora 44 | OpenZFS release RPM, DKMS | 2.4.4 |
| Rocky Linux 10 | OpenZFS release RPM + EPEL, DKMS | 2.2.11 |
These are the versions the upstream repositories shipped when the installer was
tested; the repositories decide which release you get. pg zfs status always
prints the installed userland and kernel-module versions.
Other distributions are detected and given manual instructions rather than installed automatically. Only Linux is supported.
Install
The command is zero-configuration and idempotent:
- Verifies privileges (root, or working
sudo -n). - Enables the required repository (
contribon Debian,universeon Ubuntu, the OpenZFS release repository on Fedora and Rocky). - Installs ZFS, kernel headers/module and the ZED daemon.
- Loads the module and enables the ZFS systemd units for boot.
- Prints userland and kernel-module versions.
Running it again on a healthy host is a no-op. There are no --version,
--backports or --force switches — use the distribution package manager when
you need a specific ZFS build, then let pg zfs manage it.
Verify the result at any time:
RAID levels and ZFS
ZFS has no standard RAID2/RAID3 levels. The pool types map as follows:
--type |
ZFS layout | Redundancy | Analogy |
|---|---|---|---|
stripe (raid0) |
striped vdev | none | RAID0 |
mirror (raid1) |
mirror | 1+ copies | RAID1 |
raidz1 |
single parity | 1 disk | RAID5 |
raidz2 (raid2) |
double parity | 2 disks | RAID6 |
raidz3 (raid3) |
triple parity | 3 disks | triple-parity RAID |
The aliases raid0/raid1/raid2/raid3 are accepted for convenience.
Quick start
The pool is mounted at /tank, the dataset at /tank/backups. Rollback
discards every change made after the snapshot and asks for confirmation.
Reusing disks from an old pool
ZFS keeps its vdev partitions and labels on the disks, and export does not
remove them, so the safety check refuses such disks. Destroy the old pool and
clear the leftovers before creating a new one:
If labelclear fails on the whole disk, clear the member partitions instead
(/dev/vdd1, /dev/vde1). Skip step 1 when the old pool was already destroyed.
Disk safety
pg zfs disk list reports every whole disk with its mountpoints, current ZFS
membership and whether pgcli considers it safe to hand to ZFS.
Pool creation refuses:
- the root disk, or a disk with a mounted partition;
- partitions, loop, ROM and CD-ROM devices;
- disks carrying a filesystem (
ext4,xfs, swap, LVM, LUKS, mdraid, …); - disks with an existing ZFS label;
- read-only disks;
- duplicate devices, including duplicates reached through different paths;
- disks already used by another pool.
--force skips only the confirmation prompt. It is never passed to ZFS as
-f, and it does not bypass any safety check.
Creating pools
Automatic layouts
Positional devices select the layout:
| Disks | Layout |
|---|---|
| 1 | stripe (warning: no redundancy) |
| 2 | mirror |
| 3 | raidz1 |
| 4 or more | raidz2 |
Override with --type:
Use disks of the same capacity (and similar performance) within a vdev. ZFS does not reject mixed sizes, but a vdev’s usable capacity is limited by its smallest member, so the extra space on larger disks is wasted. Different vdevs in one pool may have different capacities.
Multiple vdevs
Use repeatable --data groups together with an explicit --type. Each group is
one vdev; the pool stripes across them:
--type applies inside each --data group, not to the pool as a whole: every
group becomes one vdev of that layout, and the pool stripes across the vdevs.
All groups share the same --type; pg zfs cannot mix, for example, one mirror
vdev and one raidz2 vdev in a single create. Use pg zfs raw zpool create for a
mixed topology.
Capacity is the sum of the vdev capacities. Redundancy is per vdev: losing more disks than one vdev can tolerate takes down the whole pool.
SLOG, special, cache and spares
--special follows --type (a mirror here). --log with two or more devices is
a mirror; a single device is a stripe and is warned about. --cache and
--spare take independent device lists.
A device may appear only once across data, special, log, cache and spare.
Add a spare to an existing pool with pool add:
Default properties
Unless --no-defaults is given:
| Object | Property | Value |
|---|---|---|
| pool | ashift |
12 |
| pool | autoexpand |
on |
| pool | autotrim |
on when every data disk is non-rotational and reports discard support |
| root dataset | compression |
zstd |
| root dataset | atime |
off |
| root dataset | xattr |
sa |
| root dataset | acltype |
posixacl |
| root dataset | dnodesize |
auto |
Override pool properties with --prop/-p (maps to zpool -o) and root dataset
properties with --fs-prop/-P (maps to zpool -O):
-o is pgcli’s global output flag, which is why the pool/dataset properties use
-p/-P. Inside pg zfs raw the native flags work normally.
Managing pools
Growth and repair:
attach targets mirror vdevs. Adding disks to a raidz vdev (raidz expansion)
requires OpenZFS 2.3 or newer; Debian 12 (2.1) and Ubuntu 24.04 (2.2) cannot do
it. Use pg zfs raw zpool attach on a recent release if you need it.
Unmounting and destroying a pool
pg zfs pool export unmounts every dataset and detaches the pool from the
host, but keeps all data on the member disks:
An exported pool is not imported at boot. Attach it again by listing the importable pools and importing it:
pg zfs pool destroy deletes the pool, every dataset and all data:
destroy only works on an imported pool. If the pool was exported, import it
first:
Without --force both commands ask for confirmation; EOF and the default
answer mean “no”. Non-interactive automation must pass --force. --force
only skips the prompt — it is never passed to ZFS as -f and does not bypass
safety checks. Export is reversible; destroy is not.
Persistence and boot
Pools and datasets survive reboots without any extra steps:
pg zfs installenableszfs-load-module,zfs-import-cache,zfs-mountandzfs-zed(when the unit exists).zpool createrecords the pool in/etc/zfs/zpool.cache; on bootzfs-import-cacheimports it.- ZFS does not use
/etc/fstab. Each dataset’smountpointis a ZFS property, andzfs-mountmounts them at boot. A pool’s root dataset mounts at/<pool>, and child datasets mount under it (for example/fast/data). - The pool state lives on the member disks, so data survives a crash or power loss even if the host did not shut down cleanly.
Verify the setup:
After a reboot pg zfs pool list should list the pool and findmnt /fast
should show it mounted.
On real hardware, create pools from stable paths such as /dev/disk/by-id/....
/dev/sdX names can change across boots, and a pool whose devices are missing
will not import. Run pg zfs pool export tank before moving disks to another
host; the destination can then adopt them with pg zfs pool import tank.
Datasets and snapshots
A dataset with children requires --recursive to destroy. Rollback discards all
changes made after the snapshot.
Rollback
pg zfs snapshot rollback tank/data@snap1 returns the dataset to the state
captured by the snapshot; every change made after it is discarded. If newer
snapshots exist, ZFS refuses the rollback unless --recursive is given, which
deletes those newer snapshots. Clones that depend on the snapshot also block the
rollback — remove them first, or use pg zfs raw zfs rollback -R (which
destroys the dependent clones). Non-interactive runs need --force to skip the
confirmation prompt.
Clone
pg zfs dataset clone tank/data@snap1 tank/clone creates a new, writable
dataset from the snapshot. Unchanged blocks are shared copy-on-write, so the
clone does not duplicate the full data. Pass --prop to set properties on the
new dataset:
While a clone depends on a snapshot, the snapshot cannot be destroyed. Promote
the clone (pg zfs raw zfs promote tank/clone) to make it independent of the
original dataset before removing the old snapshot.
Raw passthrough
raw bypasses every safety check and confirmation. It is the escape hatch for
options pgcli does not wrap — treat it exactly like running zpool/zfs as
root. Binary pipelines work too, for example:
Permissions
Every command that invokes zpool or zfs first checks privileges:
-
root: runs directly;
-
non-root with working
sudo -n: all ZFS commands run throughsudo -n; -
otherwise: fails immediately with
pgcli never prompts for a sudo password. pg zfs status and pg zfs disk list
still show static information without privileges, marking pool details as
unavailable.
Command reference
| Command | Purpose |
|---|---|
pg zfs install |
Install ZFS (zero-argument, idempotent) |
pg zfs status |
Distribution, package, module and pool overview |
pg zfs disk list [--all] |
Whole disks, mounts, ZFS membership, safety |
pg zfs pool create NAME DEVICE... |
Create a pool |
pg zfs pool list |
List pools |
pg zfs pool status [NAME] [--brief] |
Detailed status |
pg zfs pool add NAME DEVICE... |
Add vdevs |
pg zfs pool attach/detach NAME DEVICE [NEW] |
Grow/shrink a mirror |
pg zfs pool replace NAME OLD NEW |
Replace a disk |
pg zfs pool offline/online NAME DEVICE |
Take a disk out/return it |
pg zfs pool export/import [NAME] |
Move a pool between systems |
pg zfs pool scrub NAME [--stop] |
Verify data integrity |
pg zfs pool destroy NAME |
Destroy a pool and all data |
pg zfs dataset create/list/set/clone/destroy |
Dataset lifecycle |
pg zfs snapshot create/list/rollback/destroy |
Snapshot lifecycle |
pg zfs raw zpool/zfs ARGS... |
Unwrapped passthrough |
Troubleshooting
pg zfs install cannot find packages. Check that the archive repository is
reachable and that contrib/universe is enabled. The installer edits only
distribution archive sources (never third-party repositories) and leaves a
*.pgcli.bak next to each modified file.
DKMS build fails. Confirm headers for the running kernel are installed
(linux-headers-$(uname -r) on Debian/Ubuntu, kernel-devel-$(uname -r) on
Fedora/Rocky). The installer reports the build log location; it never reboots or
changes the kernel. On very small VMs, add swap before installing.
Secure Boot is enabled. DKMS modules must be signed. pgcli reports the condition but does not enroll keys; sign the module with your MOK key or disable Secure Boot for the test host.
Module is not loaded. modprobe zfs then pg zfs status. The installer
enables zfs-load-module, zfs-import-cache, zfs-mount and zfs-zed so pools
import automatically on boot.
A disk is rejected because it has partitions or an old label. If it really is free, clear the leftovers:
On OpenZFS 2.2 and newer, clearing the whole disk can fail with
failed to clear label for /dev/vdb when the label lives in the ZFS member
partition. In that case clear the partition instead, then wipe the table:
Never do this to a disk that belongs to an imported pool — pg zfs disk list
shows the owning pool, and the safety check refuses such disks.
SELinux (Fedora/Rocky). ZFS mountpoints use standard file contexts. If a
service cannot access a dataset, check ausearch/journalctl for AVC denials
instead of disabling SELinux globally.
Pools do not come back after reboot. systemctl status zfs-import-cache
and zpool import as root. Pools created from /dev/sdX names follow device
renames; use stable paths (/dev/disk/by-id/...) when creating pools on real
hardware.
2 - PGMQ Queues
pgmq turns PostgreSQL into a durable message queue: no Redis, RabbitMQ or
background worker, just tables and SQL functions in the pgmq schema.
Producers send JSON messages, consumers read them with a visibility timeout,
and messages stay in the queue until they are explicitly deleted or archived.
Install
pgcli installs the extension package from the Pigsty repository and runs
CREATE EXTENSION IF NOT EXISTS pgmq in the instance’s default database:
To use it from another database in the same instance:
Check the installed version:
Quick start
Run the same statements through pgcli:
Queues on disk
Each queue is a table in the pgmq schema:
| Object | Purpose |
|---|---|
pgmq.q_<name> |
live messages |
pgmq.a_<name> |
archive populated by pgmq.archive |
pgmq.meta |
queue registry used by list_queues and metrics |
A live message row contains msg_id, read_ct (number of deliveries),
enqueued_at, vt (visible again at) and message jsonb. Recent versions add
last_read_at and headers.
Because messages are rows, pgmq.send participates in transactions: it commits
or rolls back with the surrounding statement. Queue traffic is also ordinary
table traffic — high send/delete rates bloat the table like any other.
Sending messages
send returns the new msg_id; send_batch returns one id per message. Use
send_batch instead of looping over send — it is one round trip and the whole
batch is atomic.
Payloads must be valid JSON and are stored as jsonb: keys are reordered and
duplicates removed. Keep large blobs out of the queue.
Newer versions accept a headers jsonb argument for metadata such as a trace id
or a FIFO group key.
Reading and acknowledging
read(queue, vt, qty) returns up to qty visible messages and makes them
invisible for vt seconds. Reading does not remove anything, so each message
must be acknowledged:
pgmq.pop('jobs') reads and deletes in one call. Use it only when losing an
in-flight message on a consumer crash is acceptable.
Avoid a tight polling loop in long-running consumers:
This waits up to max_poll_seconds (5) for messages, checking every
poll_interval_ms (100) milliseconds, and returns as soon as a message arrives.
Visibility timeout and retries
The visibility timeout is PGMQ’s delivery model:
readsetsvt = now() + timeoutand incrementsread_ct.- The message stays invisible to other consumers until
vt. deleteorarchiveremoves it. If the consumer crashes or does not acknowledge in time, the message becomes visible again aftervtand is delivered to another consumer.
Delivery is therefore at-least-once across failures. Choose vt longer than
the worst-case processing time, and make consumers idempotent. For long jobs,
extend the lease without re-reading:
read_ct is a ready-made retry counter.
Archive and dead letters
delete is permanent. archive moves a message to pgmq.a_<queue> for
retention, audit or replay:
A simple dead-letter pattern is to archive messages that failed too many times:
Without a rule like this, a poison message is redelivered forever.
Monitoring
metrics reports queue_length, queue_visible_length, the age of the newest
and oldest messages, and total_messages. Older extension versions return a
smaller set of columns.
The queue table can be queried directly:
Alert when queue_length keeps growing or oldest_msg_age_sec climbs: either
consumers are down or they cannot keep up.
Maintenance
create_unloggedtrades crash safety for write speed. Use it only for data that can be regenerated.create_partitionedrequires thepg_partmanextension. It keeps a very high-throughput queue from bloating a single table; old partitions are dropped automatically.purge_queueempties a queue but keeps it;drop_queueremoves it entirely.- Queue names are limited to 47 characters.
With ordinary queues, heavy send/delete traffic produces dead tuples. Make sure autovacuum keeps up, or archive messages instead of deleting them and consider partitioned queues at high volume.
Permissions
PGMQ functions run with the caller’s privileges. An application role needs access to the schema, the functions and the queue tables:
Run pgmq.create as the queue owner; creating tables requires privileges the
application role usually should not have. New queues need their own grants, or
you can pre-configure default privileges.
Function reference
| Function | Purpose |
|---|---|
pgmq.create(name) |
Create a queue |
pgmq.create_unlogged(name) |
Create an unlogged queue |
pgmq.create_partitioned(name, ...) |
Create a partitioned queue |
pgmq.send(name, msg [, delay]) |
Send one message, returns msg_id |
pgmq.send_batch(name, msgs jsonb[]) |
Send a batch atomically |
pgmq.read(name, vt, qty) |
Read and hide messages |
pgmq.read_with_poll(name, vt, qty, ...) |
Long-poll read |
pgmq.pop(name [, qty]) |
Read and delete in one call |
pgmq.delete(name, id) / pgmq.delete(name, ids[]) |
Acknowledge and remove |
pgmq.archive(name, id) / pgmq.archive(name, ids[]) |
Move to the archive table |
pgmq.set_vt(name, id(s), seconds) |
Extend the visibility timeout |
pgmq.list_queues() / pgmq.metrics(name) |
Inspect queues |
pgmq.purge_queue(name) |
Delete all messages, keep the queue |
pgmq.drop_queue(name) |
Remove the queue and its archive |
Troubleshooting
extension "pgmq" is not available. The extension package is not in the
instance image, or the extension has no build for this database. Install it with
pg extension install pgmq -i <instance> and create it in the target database.
Messages are redelivered. Processing took longer than vt, or the consumer
exited before delete. Increase vt, call set_vt during long jobs, and make
processing idempotent.
Queue length never drops. Consumers are failing or too slow. Inspect
read_ct, application logs and metrics().oldest_msg_age_sec. PGMQ retries
failed messages forever unless they are archived or deleted.
Performance degrades over time. Default queues are ordinary tables. Check
dead tuples and autovacuum, batch sends and reads, and consider
create_partitioned for sustained high throughput.
permission denied for schema pgmq. Grant USAGE on the schema and
EXECUTE on the functions to the application role, plus table privileges on
each queue it uses.
3 - SQLite + Litestream
pg sqlite manages SQLite databases directly on a Linux host and can replicate
their WAL changes to S3-compatible object storage with
Litestream. SQLite runs as a normal host process — there
is no container, no Go SQLite driver and no cgo. The sqlite3 CLI executes all
SQL; Litestream runs as one detached process per database.
Replication is asynchronous (roughly one second of RPO with the default sync interval). It is continuous backup, not high availability: there is still a single writer, and a failover means restoring a backup somewhere else.
Only Linux is supported.
How it works
- Every managed database has an entry in
pg.yamland a per-name state directory under<base>/sqlite/<name>/. pg sqlite createenablesjournal_mode=WAL, which is persistent in the database header.pg sqlite replication enablerenders a dedicatedlitestream.yml(mode0600) and startslitestream replicatein the background. Litestream tails the WAL and uploads LTX objects to the configured bucket.- S3 credentials are written only to that
litestream.yml. They are never stored inpg.yamland are never printed by anypg sqlitecommand.
Install
The command is idempotent and installs two host binaries:
sqlite3from the distribution package manager if it is not already onPATH(apt-get, dnf, yum, pacman, zypper or apk). Only this step may need root; pgcli uses passwordlesssudo -nand fails with a clear message instead of prompting for a password.- Litestream
0.5.17, downloaded from GitHub and verified against the published SHA256 checksum. It goes to/usr/local/binwhen writable, otherwise to~/.local/bin.
Useful flags:
Litestream 0.3.x is not compatible with the generated configuration; the
installer ignores older binaries on PATH and installs 0.5.x.
Create and manage databases
create never truncates an existing file: pointing it at an existing SQLite
database adopts the file, enables WAL and leaves the contents untouched.
--path must be absolute and only moves the database file — configuration,
logs, pid and local Litestream state always stay under
<base>/sqlite/<name>/.
info reads journal_mode, page size/count and freelist, then runs
PRAGMA quick_check by default. Use --integrity full for a complete
integrity_check, or --integrity none to skip it on large databases.
remove unregisters the database and stops replication but leaves the database
file in place. Add --clean-data to delete the database, WAL sidecars, local
Litestream state and logs (interactive confirmation; --force skips it).
Remote S3 backups are never deleted automatically.
Enable S3 replication
The bucket must already exist — Litestream does not create buckets. Create it
with mc mb, the cloud console, or another S3 tool first.
Local MinIO for testing
pgcli can host the object store itself with the MinIO addon:
If port 9000 was already taken, use the endpoint reported by
pg addon list instead. Then point replication at the local store:
Any other MinIO or S3-compatible store uses the same flags with its own endpoint and credentials:
AWS S3 with the default credential chain (no keys on the command line):
| Flag | Default | Notes |
|---|---|---|
--s3-bucket |
required | Must already exist |
--s3-endpoint |
AWS default | Complete http(s)://host[:port], no path or credentials |
--s3-path |
sqlite/<name> |
Object key prefix; a leading / is stripped |
--s3-region |
us-east-1 |
|
--s3-access-key / --s3-secret-key |
default chain | Provide both or neither |
--s3-uri-style |
path |
path or virtual-host |
--s3-ca-file |
none | Passed to Litestream as SSL_CERT_FILE for private CAs |
--s3-skip-verify |
false | Disable TLS verification (self-signed endpoints) |
--sync-interval |
1s |
WAL upload interval |
--snapshot-interval |
24h |
Full snapshot interval |
--retention |
24h |
Snapshot retention |
--no-start |
false | Write configuration without starting Litestream |
--force |
false | Replace an existing configuration |
enable verifies that the database is in WAL mode, resolves a 0.5.x
Litestream binary (or uses --litestream-binary PATH), writes litestream.yml
with mode 0600, clears stale local Litestream state, starts the process and
checks that it survives startup. If it exits immediately, the last log line is
printed and the command fails.
Running enable again with identical non-secret settings while replication is
running is a no-op. Different settings are rejected unless --force is given;
--force stops the running process, rewrites the configuration, resets local
state and restarts.
Where the credentials live
| Path | Contents | Mode |
|---|---|---|
<base>/pg.yaml |
name, path, binary/version, endpoint, bucket, prefix, region, URI style, CA file, skip-verify — no keys | 0644 |
<base>/sqlite/<name>/<name>.db |
database file | app default |
<base>/sqlite/<name>/litestream.yml |
full S3 configuration including access/secret keys | 0600 |
<base>/sqlite/<name>/litestream.log |
process log | 0644 |
<base>/sqlite/<name>/litestream.pid |
running pid | 0644 |
<base>/sqlite/<name>/.<db>-litestream/ |
local LTX state | app default |
Back up or copy litestream.yml with the same care as any credential file.
replication disable removes it, so credentials must be supplied again on the
next enable.
Operate replication
status combines the process state with litestream status -json (local
transaction id and WAL size) and shows the newest error line from the log.
If the status query fails, the command still reports process state and exits
successfully.
Check remote backups
pg mc wraps the bundled MinIO client; its flags must come after --. Using
the alias created in the Local MinIO example, list what Litestream has uploaded
under the configured --s3-path:
A populated prefix looks like this:
0000/ holds per-transaction WAL frames; higher-numbered levels (0001/,
0009/, …) are merged and compacted files. An empty prefix right after
enable only means no write has been synced yet — insert a row, wait at least
one --sync-interval (default 1s), then list again. The local transaction id
shown by replication status should match the highest txid in these names.
disable keeps the database, local LTX state and logs, but removes the
replication block from pg.yaml and deletes litestream.yml. The remote
backups remain untouched.
Auto-start on boot
Litestream runs as a detached host process, so it does not survive a reboot on its own. Register the database with the pgcli boot service:
--name is required (SQLite has no default database name). Replication must
already be enabled and its litestream.yml must still exist — the toggle checks
both and fails with the usual re-enable hint otherwise. At boot,
pg start --autostart restarts Litestream after the object-storage addons, so
a same-host MinIO/silo/rustfs/garage/SeaweedFS backend is listening first. The
boot path is start-only: it never rewrites litestream.yml and never touches
the database file.
pg sqlite replication disable clears this flag automatically. See
Auto-start on Boot for the systemd/launchd service details.
Restore
Litestream can restore the latest state or a point in time. Two modes are available:
--timestamp and --txid are mutually exclusive; omitting both restores the
latest state. --timestamp is an absolute instant: write it in UTC with the
Z suffix (for example 2026-01-01T12:00:00Z) so the restore point does not
depend on the host timezone. --target cannot be combined with --restart,
and an existing target requires --force, which first moves the old target and
its -wal/-shm files into <target>.pre-restore-<UTC>/.
Point-in-time recovery walkthrough
Capture a transaction boundary before making the change you want to undo:
LTX object names are <min-txid>-<max-txid>.ltx. A restore by --txid includes
the state up to and including that transaction. Record the txid (or an RFC3339
UTC timestamp taken safely before the change), then restore a copy and verify
it:
The timestamp form follows the same flow:
Use --dry-run first when unsure: it prints the transaction range that would
be used and writes nothing. Restoring to --target does not stop replication,
so traffic can continue while you inspect the copy. As a sanity check, restore
the latest state (no --timestamp/--txid) and confirm that newer rows are
present; only then promote the verified copy with an in-place restore.
In-place restore
Without --target, the configured database is replaced:
The sequence is deliberately conservative:
- Litestream is stopped (a final sync runs) and other applications must have been stopped manually first.
- The current database and its WAL sidecars are moved to
<db>.pre-restore-<UTC>/(confirmation unless--force). - The backup is restored into a temporary directory next to the database, so a failed restore never touches the preserved copy.
- The restored database is checked and switched to WAL if necessary, then moved into place with an atomic rename on the same filesystem.
- Local Litestream state is reset. If reset fails, the stale state directory is removed.
- Replication stays stopped by default.
--restartstarts it again.
Restarting replication into the same --s3-path after a restore can mix the
restored timeline with the old remote data. Prefer re-enabling with a new
--s3-path, or make sure you understand the old timeline before using
--restart.
If the database file is lost entirely, in-place restore works without
--force; the per-database litestream.yml must still exist.
WAL and application settings
Only PRAGMA journal_mode=WAL is persisted in the database file by the CLI.
Tune the following per connection from the application:
Keep in mind:
- SQLite in WAL mode requires a local filesystem. Do not put the database on NFS or SMB.
- There is still one writer at a time. Long write transactions block other writers; long read transactions prevent WAL checkpointing and can make the WAL grow without bound.
- Litestream replicates the WAL, not an application-consistent snapshot. Use transactions for multi-statement invariants.
Troubleshooting
litestream exited during startup — run
pg sqlite replication logs <name> -n 50. The usual causes are a missing
bucket, wrong credentials or an endpoint that is not reachable from the host.
no Litestream 0.5.x binary found — run pg sqlite install. A 0.3.x
binary on PATH is reported but not used.
TLS errors against MinIO/RustFS — use the full http://host:port endpoint
for plain HTTP, or --s3-ca-file / --s3-skip-verify for self-signed HTTPS.
database ... uses journal_mode=delete — the database was not created by
pg sqlite create, or another tool changed the mode. Use
PRAGMA journal_mode=WAL; with no other connections open, then retry enable.
litestream.yml not found — start, status and restore require the
configuration written by enable; re-run enable (credentials are not stored
in pg.yaml).
Limits
- Linux only; the macOS host-binary paths are not implemented.
- Litestream is asynchronous backup, not synchronous replication or HA. The
default RPO is about one second, and a final sync on
stop/disablecan take up to 30 seconds. - One Litestream process and one WAL writer per database.
--clean-dataand in-place restore are destructive; they prompt by default, and the preserved.pre-restore-*directories are left for manual cleanup.
4 - TimescaleDB
TimescaleDB is a PostgreSQL extension for time-series workloads. It turns a regular table into a hypertable: rows are partitioned into time-based chunks automatically, while applications keep using ordinary SQL, indexes and transactions.
pgcli installs the Pigsty TSL build of TimescaleDB 2.31.0. Hypertables,
time_bucket, chunk management, columnstore/compression, continuous aggregates and retention policies are available. The TSL permits free internal use but prohibits offering a time-series database as a service.
Use cases
TimescaleDB fits append-heavy data where time is the main access dimension:
- IoT and device telemetry — per-device readings, latest values and time-window aggregates.
- Infrastructure and application monitoring — CPU, memory, latency and request metrics for dashboards and alerting.
- Industrial and energy metering — high-frequency sensor streams with a long retention window.
- Financial market data — ticks, quotes and trades; OHLC-style rollups with
time_bucketandfirst/last. - Product and event analytics — user events, sessions and API calls aggregated over recent time windows.
- Smart home and building automation — sensor history and state changes.
The typical shape is append-only rows keyed by (entity_id, time): queries
filter a recent time range, and old data is expired by dropping whole chunks.
It is a poor fit for OLTP workloads without a time dimension, key-value or document storage, and blob storage. The TSL build includes compression, continuous aggregates and retention policies, but the TSL does not allow offering a time-series database as a service.
Install
pgcli builds an extension image with the Pigsty TSL package, puts
timescaledb first in shared_preload_libraries, restarts PostgreSQL and runs
CREATE EXTENSION IF NOT EXISTS timescaledb in the instance’s default
database.
To use it from another database in the same instance:
Check the installed version and license:
The Pigsty package reports timescale.
Quick start
1. Create an instance
pg create writes the configuration and prints the admin password; pg start
brings up PostgreSQL and the backup container. The default database is
tsdb_db.
2. Install the extension
pgcli builds the extension image, preloads timescaledb, restarts PostgreSQL
and runs CREATE EXTENSION IF NOT EXISTS timescaledb in tsdb_db.
3. Create a hypertable
Connect to the instance:
Create a table and convert it to a hypertable:
Or create it in one step; the default chunk interval applies and can be changed afterwards:
Choose the chunk interval to fit the query and retention window. Existing chunks keep their interval when you change it later.
4. Write data
5. Daily operations
Append new rows, inspect chunks and jobs, and enable automatic maintenance:
Operational commands:
See Compression and columnstore, Retention policies, Continuous aggregates and Managing chunks for the full set.
InfluxDB-compatible API: the influx2tsdb addon
If existing tooling speaks InfluxDB rather than SQL, the influx2tsdb addon runs a managed, stateless proxy that serves the InfluxDB 1.x HTTP API (Line Protocol writes and InfluxQL queries) on top of a TimescaleDB database:
- each InfluxDB database becomes a PostgreSQL schema, and each
measurement becomes a hypertable with
timeas the partition column; CREATE / ALTER / DROP RETENTION POLICYis translated into TimescaleDB’s ownadd_retention_policy/add_compression_policyjobs — nopg_cronis needed, and chunk cleanup continues while the proxy is stopped;- the proxy holds no data, so backup, PITR, replication and HA remain ordinary PostgreSQL operations.
The trade-off is throughput: every request is translated to SQL, so pure time-series ingest and query load is slower than native InfluxDB. The proxy has no authentication or TLS; front it with nginx or isolate the port. See the addon page for flags, the data model, retention semantics and the test scripts.
Query patterns
First and last value per series:
Latest row per series:
Daily buckets in a specific timezone:
Fill gaps in a bucket sequence:
time_bucket_gapfill needs an explicit start and finish, either as the second
and third arguments or in the WHERE clause.
Compression and columnstore
Older chunks can be moved to the columnstore, which compresses them and speeds up analytical scans:
Check the compression status and the space used:
Retention policies
Expire chunks automatically once they fall outside the retention window:
These jobs run in TimescaleDB’s own background scheduler; pg_cron is not required.
The influx2tsdb addon uses the same mechanism: its
CREATE / ALTER / DROP RETENTION POLICY statements are translated into these jobs
automatically, and cleanup continues while the proxy is stopped.
Continuous aggregates
Pre-aggregate recent data and keep the rollup refreshed in the background:
Query the rollup like a normal table:
The policy refreshes only the recent window between start_offset and
end_offset; refresh older ranges manually after a backfill.
Managing chunks
Chunk intervals
Set the interval at creation, or change it later for new chunks:
For timestamptz columns, chunk boundaries align to UTC. Choose an interval
large enough that a chunk fits comfortably in memory; a very small interval
creates many chunks and adds planning overhead.
List chunks
older_than and newer_than filter by the chunk’s time range, not by its
creation time.
Chunk sizes
Split a chunk
Split one chunk at a timestamp inside its range; take the chunk name from
show_chunks('sensor'):
split_chunk is a procedure; use CALL, not SELECT.
Remove chunks
drop_chunks removes files instead of producing dead tuples. Avoid deleting
large time ranges with DELETE. For automatic expiration prefer a retention
policy; pg_cron is only needed for a custom schedule:
Manual columnstore conversion
With compression settings configured (see Compression and columnstore), convert a single chunk without waiting for the policy:
Both procedures take a chunk name, not the hypertable name.
Metadata views
jobs also lists the columnstore, retention and continuous-aggregate policies
created above, alongside the internal job-history task.
Feature availability
| Feature | Status |
|---|---|
Hypertables, time_bucket, first/last, show_chunks, drop_chunks, hypertable_size, metadata views |
✅ Apache |
Space partitioning with add_dimension / by_hash |
✅ Apache |
Columnstore and compression (ALTER TABLE ... SET (timescaledb.compress), CALL add_columnstore_policy) |
✅ TSL |
Continuous aggregates (CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)) |
✅ TSL |
Retention policies (add_retention_policy) |
✅ TSL |
time_bucket_gapfill |
✅ TSL |
Rows marked ✅ Apache follow Apache-2.0. Rows marked ✅ TSL come from the Timescale License: free for internal use, but offering a time-series database as a service is not permitted.
Backup and restore
Logical backups and pg export/pg import work as usual. A physical restore
(pgBackRest, used by pg backup/pg restore) also needs TimescaleDB’s restore
hooks:
pg restore does not run these automatically, so run timescaledb_post_restore()
after the restore completes. Test the procedure before relying on it.
Permissions
CREATE EXTENSION requires a superuser; pg exec connects as the instance
admin. The owner of a table can convert it with create_hypertable and manage
its chunks. Application roles need only ordinary table privileges.
Troubleshooting
extension "timescaledb" is not available. The package is missing from the
instance image, or the extension was not created in the target database. Run
pg extension install timescaledb -i <instance> and create it in that database.
timescaledb is not preloaded. The extension is in
shared_preload_libraries but PostgreSQL was not restarted. pg extension install handles this; verify with SHOW shared_preload_libraries; and restart
the instance.
License errors for a function. The instance image still contains the
Apache build (an older pgcli release or a manually installed PGDG package).
Reinstall with pg extension install timescaledb -i <instance> --auto-restart
to switch to the Pigsty TSL package.
cannot create a unique index without the column "time". Every unique
index and primary key on a hypertable must include the partitioning column:
Too many chunks. Lower the chunk interval with
set_chunk_time_interval; existing chunks are not rewritten.
Old data does not disappear. Add a retention policy with
add_retention_policy, or run drop_chunks manually. Check
timescaledb_information.jobs for the policy job.
Function reference
| Function | Purpose | Status |
|---|---|---|
create_hypertable('t', by_range('time')) |
Convert a table to a hypertable | ✅ |
by_range('time', INTERVAL '1 day') |
Time partitioning with a chunk interval | ✅ |
set_chunk_time_interval('t', INTERVAL ...) |
Change the interval for new chunks | ✅ |
time_bucket('1 hour', time [, timezone]) |
Time-based aggregation buckets | ✅ |
first(value, time) / last(value, time) |
First/last value in a group | ✅ |
show_chunks('t' [, older_than/newer_than => ...]) / drop_chunks('t', ...) |
Inspect, filter and drop chunks | ✅ |
hypertable_size('t') / hypertable_detailed_size('t') |
Hypertable size and breakdown | ✅ |
add_dimension('t', by_hash('col', N)) |
Add space partitioning | ✅ |
add_retention_policy(...) / remove_retention_policy(...) |
Automatic chunk expiration | ✅ TSL |
CALL add_columnstore_policy(...) |
Columnstore/compression policy | ✅ TSL |
CALL convert_to_columnstore(chunk) / CALL convert_to_rowstore(chunk) |
Manual chunk storage conversion | ✅ TSL |
hypertable_columnstore_stats('t') / chunk_columnstore_stats('t') |
Columnstore statistics | ✅ TSL |
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) |
Continuous aggregates | ✅ TSL |
add_continuous_aggregate_policy(...) |
Continuous aggregate refresh policy | ✅ TSL |
time_bucket_gapfill(...) |
Fill missing buckets | ✅ TSL |