Inspecting the cluster: your topology is a query
By the end of this page you will know every column in scram.nodes, scram.shards, and scram.freshness, what a NULL in any of them actually promises, and four worked queries, two of them real joins, for questions you actually have about a running cluster.
One source, two surfacesβ
ScramDB gives you two ways to look at a running cluster, and they are not two implementations that happen to agree today: they are one function's output, presented twice. SHOW CLUSTER, SHOW SHARDS, and SHOW FRESHNESS are admin statements for a fast look from psql. scram.nodes, scram.shards, and scram.freshness are ordinary tables living in the reserved scram schema, built from the exact same provider. Same rows, same column names, same values, in two spellings that structurally cannot disagree, because neither one re-derives anything the other already computed.
The difference is what you can do with each. SHOW CLUSTER gives you text to read. scram.nodes gives you a table to query: filter it, join it against your own tables, aggregate it, hand it to whatever already speaks SQL, including an AI agent that needs columns to reason over instead of a screen of text to parse. Your topology is a query you write, not a CLI output you have to parse.
Watch one three-node cluster through both spellings. SHOW CLUSTER always prints every column, so its expanded display (\x) is the easiest way to read a row this wide:
\x
SHOW CLUSTER;
-[ RECORD 1 ]-------+---------------
node_id | node-a
advertise_addr | 10.0.0.1:7190
region | us-east
zone | a
role | voter
state | live
quarantined_groups | 0
group0_voter | t
groups_led | 4
groups_hosted | 9
is_self | t
-[ RECORD 2 ]-------+---------------
node_id | node-b
advertise_addr | 10.0.0.2:7190
region | us-east
zone | b
role | voter
state | live
quarantined_groups |
group0_voter | t
groups_led |
groups_hosted |
is_self | f
-[ RECORD 3 ]-------+---------------
node_id | node-c
advertise_addr | 10.0.0.3:7190
region | us-west
zone | a
role | learner
state | live
quarantined_groups |
group0_voter | f
groups_led |
groups_hosted |
is_self | f
SELECT node_id, is_self, quarantined_groups, groups_led, groups_hosted
FROM scram.nodes
ORDER BY node_id;
node_id | is_self | quarantined_groups | groups_led | groups_hosted
---------+---------+--------------------+------------+---------------
node-a | t | 0 | 4 | 9
node-b | f | | |
node-c | f | | |
Same values, every time. SHOW CLUSTER prints an unmeasured count as a blank cell, matching how psql prints a NULL by default. scram.nodes gives you the identical absence as a real SQL NULL, one you can test with IS NULL, wrap in COALESCE, or simply let propagate through a WHERE clause like every other NULL in ScramDB, using the same BIGINT / TEXT / BOOLEAN types your own tables use, nothing introspection-only about them. Learn one spelling's column names and you already know the other's: both are generated from the same list, per relation, so they cannot drift apart.
NULL means not measured, never zeroβ
Every nullable column across these three relations follows one rule without exception: NULL means this node has not measured that value. Not zero, not false, not "assume the best case." A cell is only ever a real number, a real leader, or an honest admission that nobody has checked yet.
Replica lag is the clearest case. If a node cannot currently determine how far behind a replica is, scram.freshness.lag_ms reads NULL, not 0. Printing 0 there would be a fabricated measurement, one that claims "perfectly caught up" about a value nobody actually took. A monitoring system that quietly defaults an unmeasured lag to zero is the worst kind of wrong: it looks like the best kind of right on a dashboard.
The same discipline holds for a peer's quarantined_groups, groups_led, and groups_hosted in scram.nodes: today, a node speaks with authority about these three counts only for itself. Ask a peer's row for them and you get NULL, not a guessed 0, until that peer's own count reaches you. scram.shards.leader and scram.freshness.leader follow the identical rule: NULL, never a stale guess, the moment this node's own view of a group's leadership is out of date.
This is not just a display convention, it changes how your queries behave correctly without any extra work from you. SQL's three-valued logic means NULL > 5000 is neither true nor false, so a WHERE lag_ms > 5000 alert never mistakes "nobody has checked" for "everything is fine": it simply does not fire for a row it cannot yet judge, rather than fire on a fabricated zero or hide a real problem behind one. Write your thresholds as if any number might be missing, because in an honest system, sometimes it is.
scram.nodes: every node this one can seeβ
One row per node in group0 (the cluster's shared metadata group) membership, voters and learners together, always including the node you are connected to once it is part of the cluster, even before it is fully joined. (A genuinely single-node instance, not part of any cluster at all, is a different case, covered below.)
| Column | Type | Meaning |
|---|---|---|
node_id | TEXT | The node's identifier. |
advertise_addr | TEXT | The address this node advertises to its peers. |
region | TEXT | The node's configured region. Empty when regions are not configured. |
zone | TEXT | The node's configured zone. Empty when zones are not configured. |
role | TEXT | voter or learner: this node's group0 membership role. Not a per-shard role, see scram.shards.learners below. |
state | TEXT | live, draining, or unreachable, as this node currently sees it. |
quarantined_groups | BIGINT | Shard groups this node has quarantined under the recovery ladder. NULL on every row except the connected node's own. |
group0_voter | BOOLEAN | Whether this node is a group0 voter, the same fact as role carried as a real boolean, so WHERE group0_voter runs as a predicate instead of a string comparison. |
groups_led | BIGINT | Shard groups this node currently leads, as seen locally. Same NULL-unless-self rule as quarantined_groups. |
groups_hosted | BIGINT | Shard groups this node hosts a replica of, led or not. Same NULL-unless-self rule. Read next to groups_led, it tells you whether leadership is balanced or piled onto one node. |
is_self | BOOLEAN | True for exactly one row: the node that answered this query. WHERE is_self is a portable "who am I." |
role describes cluster-level (group0) membership; see Learner nodes for what that role does and does not give you. quarantined_groups, groups_led, and groups_hosted are today's honest limit on this relation: a node can speak with authority about its own recovery and leadership state, not yet about a peer's, so every peer row reads NULL for these three until gossip carries the number across.
scram.shards: where every bucket livesβ
One row per bucket of every distributed table: which shard group owns it, and every node that holds a copy.
| Column | Type | Meaning |
|---|---|---|
table | TEXT | The table this bucket belongs to. |
bucket | BIGINT | The bucket number within that table. |
group_id | BIGINT | The shard group that owns this bucket. Join target for scram.freshness.group_id. |
leader | TEXT | The group's current leader, as this node knows it. NULL when this node does not know. |
replicas | TEXT | The node ids holding a voting replica of this bucket, formatted as a Postgres-style array literal ({node-a,node-b,node-c}). |
learners | TEXT | The node ids holding this bucket group as a non-voting learner, same {...} formatting. |
home_region | TEXT | The bucket's configured home region. Empty when the table has no region homing configured. |
pending_owners | TEXT | The owner set an in-flight region re-home is converging toward, same {...} formatting. Empty ({}) when no move is pending. |
replicas, learners, and pending_owners are TEXT, not a native array type, even though they print with array-literal punctuation: match a member with LIKE '%node-b%', not = ANY(...). An empty set renders {}, never NULL, because an empty replica set is a known fact about the row, not a missing measurement, the same distinction home_region makes: an empty string there means "not applicable, and that is a known fact," a different claim from NULL's "applicable, but not yet measured." pending_owners is what to watch after ALTER TABLE ... SET (home_region): a non-empty set means a re-home is still converging, and a drain back to {} means it finished.
learners here is a per-shard-group role, independent of a node's own cluster-level role in scram.nodes. A group0 voter can hold a given bucket as a non-voting learner of that specific group, and a group0 learner can hold one as a full voting replica. They answer different questions; do not conflate the two.
Because table is a SQL keyword, quote it when you reference the column, as in the join below: s."table".
scram.freshness: how far behind, in real unitsβ
One row per shard group this node has state for: how caught up its own copy is, and, when it is not, which of two different problems is the reason.
| Column | Type | Meaning |
|---|---|---|
group_id | BIGINT | The shard group this row reports on. Join target for scram.shards.group_id. |
safe_ts | BIGINT | The commit timestamp up to which every write is guaranteed applied here: this group's bounded-staleness read floor. |
applied_index | BIGINT | The highest raft log index this replica has durably applied. |
lag_ms | BIGINT | Milliseconds this replica is behind. NULL when this node cannot currently measure it; never 0 standing in for "unknown." |
leader | TEXT | This group's leader, as this node knows it. NULL when unknown. |
apply_backlog | BIGINT | Entries already committed but not yet applied here. |
is_voter | BOOLEAN | Whether this node hosts the group as a voter. false means learner. |
apply_backlog is what separates two failures that look identical from lag_ms alone: a backlog near zero with real lag means an idle group whose last write is simply old, while a backlog that is actually growing means this replica cannot apply committed entries fast enough. Same symptom, different fix, and apply_backlog is the column that tells them apart.
safe_ts matters chiefly for a columnar learner serving a bounded-staleness read; a voter group reports 0 here by design, since its own reads are leader-fenced rather than safe-ts gated, not because ScramDB failed to measure it. Zero is the honest, structural answer there, the same way an unconfigured home_region is honestly empty rather than NULL. Three different signals appear across these tables (a real NULL for "not measured," an empty string for "not applicable," and a structural 0 for "this concept does not apply to this row's kind"), and none of them is ever silently substituted for another.
Query it like any other tableβ
Because these are real tables with real types, not a fixed-format dump, you write the question directly instead of waiting for a flag that may not exist yet. Four you will actually reach for.
Which groups this node is behind onβ
SELECT group_id, lag_ms, apply_backlog, is_voter
FROM scram.freshness
WHERE lag_ms > 1000
ORDER BY lag_ms DESC;
Only rows with a real, measured lag over one second qualify. A group this node cannot currently measure is excluded from the list, not zeroed into a false pass or scored into a false alarm.
Replica placement and freshness for one tableβ
SELECT s.bucket, s.group_id, s.leader, s.replicas, s.learners,
f.lag_ms, f.apply_backlog
FROM scram.shards s
LEFT JOIN scram.freshness f ON f.group_id = s.group_id
WHERE s."table" = 'orders'
ORDER BY s.bucket;
One row per bucket of orders, its full replica set, and how caught up each shard group is. LEFT JOIN because a bucket this node does not currently track freshness for should still show its placement rather than disappear from the report.
Leader distribution across regionsβ
SELECT n.region, COUNT(*) AS leaders_hosted
FROM scram.shards s
JOIN scram.nodes n ON n.node_id = s.leader
GROUP BY n.region
ORDER BY leaders_hosted DESC;
A skewed count, one region holding most of the leadership, means that region is doing most of the write-serving work: useful the moment you are deciding where the next node should go.
Which nodes are quarantining groupsβ
SELECT node_id, region, zone, quarantined_groups
FROM scram.nodes
WHERE quarantined_groups > 0;
Today this reliably reports the connected node's own recovery state: a peer's quarantined_groups is NULL until that peer reports on itself, and NULL > 0 is neither true nor false, so a peer never false-positives into this list, it is simply excluded until there is a real answer to give. Run it against each node if you need the whole cluster's quarantine picture.
On a single node, every relation is honestly emptyβ
A genuinely single-node instance, no [cluster] section configured at all, has no peers, no shard groups spread across anyone, and nothing to quarantine. scram.nodes, scram.shards, and scram.freshness all read back zero rows there, correctly, not an error: there is no cluster to report on, so the true answer is nothing.
That is what makes a monitoring query, a health check, or a dashboard written against these three relations portable: point the same, unmodified query at a single node instead of a cluster and it keeps working, reporting nothing to see, honestly, instead of failing to parse or refusing the query outright.
SHOW CLUSTER, SHOW SHARDS, and SHOW FRESHNESS do not share that guarantee. On a genuinely single-node instance, cluster mode was never entered, so those three names are not recognized session parameters at all, and PostgreSQL parity treats an unrecognized SHOW target as an error, unrecognized configuration parameter, rather than a silent empty answer (an empty result there would wrongly imply a cluster exists with zero nodes). One more reason to reach for the table when you cannot be sure in advance whether the other end is a cluster or a single node: it is the one spelling that never needs a special case for "might not be a cluster."
scram is reserved and read-onlyβ
scram is an engine-owned schema, the same reserved category as pg_catalog and information_schema. Its three relations are rendered from live state on every scan; there is no storage behind them to write to, so an accepted write would silently do nothing, which is worse than a refusal. DDL and DML against anything in scram are refused, loudly, naming the reason:
CREATE TABLE scram.my_table (id INTEGER);
ERROR: CREATE TABLE is not allowed on 'scram.my_table': 'scram' is an engine-owned schema whose relations are rendered from live state, not stored - there is nothing to modify
INSERT INTO scram.nodes (node_id) VALUES ('fake');
ERROR: INSERT is not allowed on 'scram.nodes': 'scram' is an engine-owned schema whose relations are rendered from live state, not stored - there is nothing to modify
The same guard covers UPDATE, DELETE, TRUNCATE, MERGE, and DROP TABLE against anything in scram, one function behind every check, so the rule can never diverge between statement types. CREATE SCHEMA scram and DROP SCHEMA scram are refused too, for the simpler reason that the name is already taken by the engine itself.
None of this touches reads. SELECT, including a join against your own tables exactly like the queries above, is exactly what these three relations are for.
Nextβ
- Scaling for what a live add or remove looks like from underneath the counts on this page.
- Failover for what a node's
stateand a group'sleaderdo the moment a peer goes down. - Distributed Queries for how a query gets planned across the nodes
scram.nodeslists.