Postgres told me I had no replica. It was lying politely.
10 November 2026 · ThaiPo
Briefly, our production database was a single point of failure. We had two independent pieces of evidence, we wrote it into three documents, and the obvious next step was to pay for high availability.
The database had been a two-node pair with automatic failover the entire time. Nothing was broken except our reading of it. This is the story of how a status line and a system view teamed up to tell a confident lie, and the one-line query that caught them.

Exhibit A: "Replicas: 1"
ThaiPo keeps its account state in our managed Postgres on Fly.io, on a shared-CPU plan. One day we ran the status command for the cluster. It printed, among other things:
Replicas: 1One replica. One node. One thing to fail. It reads that way to anyone who has ever set replicas: 1 in a deployment manifest, where it means exactly one copy in total.
Here it means one replica in addition to the primary. Two nodes. Every plan of the product runs a primary plus a streaming standby. But we did not know that yet, and a careful engineer does not trust one line of CLI output. A careful engineer verifies.
Exhibit B: the verification
Postgres will tell you about its standbys directly. So we connected and asked:
SELECT * FROM pg_stat_replication;
-- one row, every column that matters NULLA row of almost nothing. No address, no state, no positions. On screen that is indistinguishable from "no standby connected", and it agreed perfectly with Exhibit A. Two sources, same answer. We wrote it down: single node, no failover, fix this.
Read that comment again, though. One row. An empty system returns zero rows. Something was there, and Postgres was declining to describe it.
NULL is not zero
Postgres statistics views have a behaviour that is documented, reasonable, and a superb trap. If your role is not allowed to see the details of someone else's session, the view does not raise a permission error. It returns the row and blanks the columns that describe it.
The application's database role holds pg_read_all_data, so it can read every table. It does not hold pg_read_all_stats, which is a separate built-in role. Reading all the data sounds like it should cover reading a small view about replication. It does not, and nothing tells you.
An error would have sent us to the documentation in thirty seconds. A masked row sent us to write "single point of failure" in three places. Permission-masked output is the most convincing kind of wrong, because it has the right shape, arrives without complaint, and happens to match what you already believe.
Ask something that cannot be masked
The fix is almost insultingly small:
SELECT count(*) FROM pg_stat_replication;
-- 1Postgres can hide what a row says. It cannot hide that the row exists. One standby, connected, exactly as the plan promised.
There is also a general tell for this database, useful long after this incident. Run SELECT backend_type FROM pg_stat_activity. If every session that is not your own role's comes back NULL, you are not looking at an idle system. You are looking at a system through frosted glass. Nothing you conclude about replication, activity or locks from that position is worth anything, and the choices are count(*) or a more privileged role.
The masking is selective, which is what makes it so plausible. Database-wide counters and per-table statistics read normally under the same role. You get just enough working telemetry to trust the part that is blank.
What we nearly did
The wrong answer survived long enough to reach three documents. Nothing was purchased. There is no resize command for these clusters, so "buying high availability" would have meant creating a new cluster and restoring into it: a production database migration, with its own real risk, to obtain a standby that was already attached and streaming.
We have form with this database fooling us. On a shared-CPU plan it throttles when its burst allowance runs out, and a long benchmarking session is precisely the thing that runs it out. We once measured a SELECT 1 that executed in 0.014 milliseconds on the server and took 150 to 220 on the wall clock, then two milliseconds again ten minutes later, and our own diagnostic load had probably been helping keep it slow. The instrument and the patient were the same machine.
What the standby does not protect
Having talked ourselves out of one false alarm, it would be easy to swing to the opposite error and file the database under "highly available, done". So here is the honest list of what that second node does not cover.
- The last few transactions. Replication is asynchronous (
synchronous_standby_namesis empty). A commit is acknowledged without waiting for the standby, so a failover can lose whatever was in flight. The recovery point is small, and it is not zero. - The region. Both nodes sit in the same region. The pair survives a node failing. It does not survive the building.
- The account.Both nodes, and the provider's own backups of them, live under one provider account. Lose the account and they go together.
That last one is why the backups that count live elsewhere. A scheduled job on a different company's infrastructure dumps Postgres every six hours to object storage on a third provider, and mirrors the message log there daily with a copy that only ever adds. A failed run sends an email. Our rule is blunt: a backup that lives inside the account you are afraid of losing is not a backup for that fear.
The short version
- A NULL is not a zero, and one row of NULLs is not zero rows. Count before you read.
- When a system answers without an error, check whether it was allowed to answer. Silence from an unprivileged role is not evidence.
- Two sources agreeing means little if you misread both in the same direction. Ours shared a single author: us.
- Write down what your redundancy does not cover, next to the claim that you have it. The list is short, slightly uncomfortable, and the most useful paragraph in the runbook.
The standby has been there since the first day, replaying every write, never once mentioned. We owe it an apology and a privileged role.
ThaiPo is designed, built and run by one developer, Steven Acreman.
Try it in your own chats
ThaiPo lives inside LINE and translates every message in both directions, free forever. Get started free or add @thaipo.ai as a friend on LINE and it sends your signup link right in the chat.